ani-mcp 0.14.0 → 0.14.2

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
@@ -4,11 +4,11 @@
4
4
 
5
5
  # ani-mcp
6
6
 
7
- [![npm version](https://img.shields.io/npm/v/ani-mcp)](https://www.npmjs.com/package/ani-mcp)
7
+ [![npm version](https://img.shields.io/npm/v/ani-mcp?color=blue)](https://www.npmjs.com/package/ani-mcp)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/ani-mcp)](https://www.npmjs.com/package/ani-mcp)
9
9
  [![CI](https://github.com/gavxm/ani-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/gavxm/ani-mcp/actions/workflows/ci.yml)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
11
- [![Node](https://img.shields.io/node/v/ani-mcp)](https://nodejs.org)
11
+ [![MCP Bundle](https://img.shields.io/badge/Claude_Desktop-.mcpb-blueviolet)](https://github.com/gavxm/ani-mcp/releases/latest)
12
12
 
13
13
  A smart [MCP](https://modelcontextprotocol.io) server for [AniList](https://anilist.co) that understands your anime and manga taste - not just raw API calls.
14
14
 
@@ -28,7 +28,11 @@ Plus the essentials: search, details, trending, seasonal browsing, list manageme
28
28
 
29
29
  ## Try it in 30 seconds
30
30
 
31
- No account needed. Paste this into your MCP client config and start asking about anime:
31
+ No account needed. Works with any MCP-compatible client.
32
+
33
+ ### Claude Desktop
34
+
35
+ Add to your config file (`Settings > Developer > Edit Config` or `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
32
36
 
33
37
  ```json
34
38
  {
@@ -41,9 +45,19 @@ No account needed. Paste this into your MCP client config and start asking about
41
45
  }
42
46
  ```
43
47
 
44
- This gives you search, trending, seasonal browsing, staff lookup, and more - no env vars required.
48
+ Restart Claude Desktop after saving.
49
+
50
+ Alternatively, download `ani-mcp.mcpb` from the [latest release](https://github.com/gavxm/ani-mcp/releases/latest) and install via `Settings > Extensions`.
45
51
 
46
- To unlock personalized features (recommendations, taste profiling, list management), add your username:
52
+ ### Claude Code
53
+
54
+ ```sh
55
+ claude mcp add ani-mcp -- npx -y ani-mcp
56
+ ```
57
+
58
+ ### Personalized features
59
+
60
+ Add your username for recommendations, taste profiling, and list management:
47
61
 
48
62
  ```json
49
63
  {
@@ -61,8 +75,6 @@ To unlock personalized features (recommendations, taste profiling, list manageme
61
75
 
62
76
  For write operations (updating progress, scoring, list edits), also add `ANILIST_TOKEN`. See [Environment Variables](#environment-variables) for details.
63
77
 
64
- Works with any MCP-compatible client.
65
-
66
78
  ## Environment Variables
67
79
 
68
80
  | Variable | Required | Description |
@@ -276,4 +288,4 @@ Bug reports and feature requests: [GitHub Issues](https://github.com/gavxm/ani-m
276
288
 
277
289
  ## License
278
290
 
279
- MIT
291
+ [MIT](LICENSE)
@@ -29,8 +29,7 @@ export interface QueryOptions {
29
29
  }
30
30
  /** Manages authenticated requests to the AniList GraphQL API */
31
31
  declare class AniListClient {
32
- private token;
33
- constructor();
32
+ private get token();
34
33
  /** Execute a GraphQL query with caching and automatic retry */
35
34
  query<T = unknown>(query: string, variables?: Record<string, unknown>, options?: QueryOptions): Promise<T>;
36
35
  /** Fetch a user's media list groups with metadata (name, status, isCustomList) */
@@ -74,10 +74,9 @@ export class AniListApiError extends Error {
74
74
  }
75
75
  /** Manages authenticated requests to the AniList GraphQL API */
76
76
  class AniListClient {
77
- token;
78
- constructor() {
79
- // Optional - unauthenticated requests still work for public data
80
- this.token = process.env.ANILIST_TOKEN || undefined;
77
+ // Read token lazily so env sanitization in index.ts runs first
78
+ get token() {
79
+ return process.env.ANILIST_TOKEN || undefined;
81
80
  }
82
81
  /** Execute a GraphQL query with caching and automatic retry */
83
82
  async query(query, variables = {}, options = {}) {
@@ -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 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";
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 category\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 { 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 extraLarge }\n trailer { id site thumbnail }\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 category\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 { 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 extraLarge }\n trailer { id site thumbnail }\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 category\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 { 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 extraLarge }\n trailer { id site thumbnail }\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 category\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 { 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 extraLarge }\n trailer { id site thumbnail }\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 category\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 { 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 extraLarge }\n trailer { id site thumbnail }\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 category\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 { 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 extraLarge }\n trailer { id site thumbnail }\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 category\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 { 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, $language: StaffLanguage) {\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: $language) {\n id\n name { full native }\n language\n siteUrl\n }\n }\n }\n }\n }\n";
25
25
  /** Airing schedule for currently airing anime */
@@ -33,13 +33,13 @@ export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaLis
33
33
  /** Fetch a single list entry for snapshotting before mutations */
34
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";
35
35
  /** Fetch a single list entry with full media details */
36
- export declare const LIST_LOOKUP_QUERY = "\n query ListLookup($mediaId: Int!, $userName: String!) {\n MediaList(mediaId: $mediaId, userName: $userName) {\n id\n status\n score(format: POINT_10)\n progress\n progressVolumes\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\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";
36
+ export declare const LIST_LOOKUP_QUERY = "\n query ListLookup($mediaId: Int!, $userName: String!) {\n MediaList(mediaId: $mediaId, userName: $userName) {\n id\n status\n score(format: POINT_10)\n progress\n progressVolumes\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\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 category\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 { extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
37
37
  /** Remove a list entry */
38
38
  export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
39
39
  /** User's anime/manga list, grouped by status. Omit $status to get all lists. */
40
- 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";
40
+ 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 category\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 { extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
41
41
  /** Completed list entries filtered by date range (server-side) */
42
- export declare const COMPLETED_BY_DATE_QUERY = "\n query CompletedByDate(\n $userName: String!\n $type: MediaType\n $completedAfter: FuzzyDateInt\n $completedBefore: FuzzyDateInt\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { hasNextPage }\n mediaList(\n userName: $userName\n type: $type\n status: COMPLETED\n completedAt_greater: $completedAfter\n completedAt_lesser: $completedBefore\n sort: FINISHED_ON_DESC\n ) {\n id\n score(format: POINT_10)\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 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";
42
+ export declare const COMPLETED_BY_DATE_QUERY = "\n query CompletedByDate(\n $userName: String!\n $type: MediaType\n $completedAfter: FuzzyDateInt\n $completedBefore: FuzzyDateInt\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { hasNextPage }\n mediaList(\n userName: $userName\n type: $type\n status: COMPLETED\n completedAt_greater: $completedAfter\n completedAt_lesser: $completedBefore\n sort: FINISHED_ON_DESC\n ) {\n id\n score(format: POINT_10)\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 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 category\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 { extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
43
43
  /** Search for staff by name with their top works */
44
44
  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";
45
45
  /** Authenticated user info */
@@ -27,6 +27,7 @@ const MEDIA_FRAGMENT = `
27
27
  tags {
28
28
  name
29
29
  rank
30
+ category
30
31
  isMediaSpoiler
31
32
  }
32
33
  season
@@ -38,7 +39,7 @@ const MEDIA_FRAGMENT = `
38
39
  }
39
40
  source
40
41
  isAdult
41
- coverImage { large extraLarge }
42
+ coverImage { extraLarge }
42
43
  trailer { id site thumbnail }
43
44
  siteUrl
44
45
  description(asHtml: false)
@@ -9,7 +9,7 @@ export interface GenreCalibration {
9
9
  }
10
10
  export interface CalibrationResult {
11
11
  overallDelta: number;
12
- tendency: "generous" | "harsh" | "average";
12
+ tendency: "high" | "low" | "balanced";
13
13
  genreCalibrations: GenreCalibration[];
14
14
  totalScored: number;
15
15
  }
@@ -14,7 +14,7 @@ export function computeCalibration(entries) {
14
14
  if (scored.length === 0) {
15
15
  return {
16
16
  overallDelta: 0,
17
- tendency: "average",
17
+ tendency: "balanced",
18
18
  genreCalibrations: [],
19
19
  totalScored: 0,
20
20
  };
@@ -58,10 +58,10 @@ export function computeCalibration(entries) {
58
58
  // Sort by absolute delta descending
59
59
  genreCalibrations.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
60
60
  const tendency = overallDelta >= 0.5
61
- ? "generous"
61
+ ? "high"
62
62
  : overallDelta <= -0.5
63
- ? "harsh"
64
- : "average";
63
+ ? "low"
64
+ : "balanced";
65
65
  return {
66
66
  overallDelta,
67
67
  tendency,
@@ -65,8 +65,8 @@ function watermark(cardW, cardH) {
65
65
  /** Build an SVG taste profile card */
66
66
  export function buildTasteCardSvg(username, profile, avatarB64 = null) {
67
67
  const genres = profile.genres.slice(0, 6);
68
- const tags = profile.tags.slice(0, 5);
69
- const formats = profile.formats.slice(0, 4);
68
+ const tags = profile.themes.slice(0, 5);
69
+ const formats = profile.formats.slice(0, 6);
70
70
  const { scoring } = profile;
71
71
  const parts = [
72
72
  svgHeader(CARD_WIDTH, CARD_HEIGHT),
@@ -84,7 +84,7 @@ export function buildTasteCardSvg(username, profile, avatarB64 = null) {
84
84
  ...statRow(40, 74, [
85
85
  { label: "Completed", value: String(profile.totalCompleted) },
86
86
  { label: "Mean Score", value: scoring.meanScore.toFixed(1) },
87
- { label: "Tendency", value: capitalize(scoring.tendency) },
87
+ { label: "Scoring", value: capitalize(scoring.tendency) },
88
88
  { label: "Median", value: String(scoring.median) },
89
89
  ]),
90
90
  // Radar chart (left)
@@ -295,7 +295,7 @@ function tagList(tags, x, y) {
295
295
  function formatBar(formats, x, y, totalWidth) {
296
296
  const barHeight = 16;
297
297
  const rx = 8;
298
- const colors = [BRAND_BLUE, "#06d6a0", "#ffd166", "#ef476f"];
298
+ const colors = [BRAND_BLUE, "#06d6a0", "#ffd166", "#ef476f", "#b388ff", "#4dd0e1"];
299
299
  const lines = [];
300
300
  // Rounded container clip
301
301
  const clipId = `fbar-${x}-${y}`;
@@ -10,7 +10,7 @@ export interface ScoringPattern {
10
10
  median: number;
11
11
  totalScored: number;
12
12
  distribution: Record<number, number>;
13
- tendency: "generous" | "harsh" | "average";
13
+ tendency: "high" | "low" | "balanced";
14
14
  }
15
15
  export interface FormatBreakdown {
16
16
  format: string;
@@ -20,6 +20,7 @@ export interface FormatBreakdown {
20
20
  export interface TasteProfile {
21
21
  genres: WeightedItem[];
22
22
  tags: WeightedItem[];
23
+ themes: WeightedItem[];
23
24
  scoring: ScoringPattern;
24
25
  formats: FormatBreakdown[];
25
26
  totalCompleted: number;
@@ -9,6 +9,8 @@ const MIN_ENTRIES = 5;
9
9
  const UNSCORED = 0;
10
10
  // Cap the number of tags returned to keep output focused
11
11
  const MAX_TAGS = 20;
12
+ // Tags must appear in at least this many entries to rank
13
+ const MIN_TAG_COUNT = 3;
12
14
  // Recency decay: entries from HALF_LIFE years ago get ~50% weight
13
15
  const DECAY_HALF_LIFE_YEARS = 3;
14
16
  const DECAY_LAMBDA = Math.LN2 / DECAY_HALF_LIFE_YEARS;
@@ -25,12 +27,14 @@ export function buildTasteProfile(entries) {
25
27
  }
26
28
  const genres = computeGenreWeights(scored);
27
29
  const tags = computeTagWeights(scored);
30
+ const themes = computeTagWeights(scored, "Theme");
28
31
  const scoring = computeScoringPattern(scored);
29
32
  // Format breakdown uses all entries, not just scored ones
30
33
  const formats = computeFormatBreakdown(entries);
31
34
  return {
32
35
  genres,
33
36
  tags,
37
+ themes,
34
38
  scoring,
35
39
  formats,
36
40
  totalCompleted: entries.length,
@@ -60,11 +64,11 @@ export function describeTasteProfile(profile, username) {
60
64
  }
61
65
  // Scoring tendency
62
66
  const { scoring } = profile;
63
- const tendencyDesc = scoring.tendency === "generous"
64
- ? `Scores generously (avg ${scoring.meanScore.toFixed(1)} vs site avg ${SITE_MEAN})`
65
- : scoring.tendency === "harsh"
66
- ? `Scores harshly (avg ${scoring.meanScore.toFixed(1)} vs site avg ${SITE_MEAN})`
67
- : `Scores close to average (avg ${scoring.meanScore.toFixed(1)})`;
67
+ const tendencyDesc = scoring.tendency === "high"
68
+ ? `Scores high (avg ${scoring.meanScore.toFixed(1)} vs site avg ${SITE_MEAN})`
69
+ : scoring.tendency === "low"
70
+ ? `Scores low (avg ${scoring.meanScore.toFixed(1)} vs site avg ${SITE_MEAN})`
71
+ : `Scores near average (avg ${scoring.meanScore.toFixed(1)})`;
68
72
  lines.push(`${tendencyDesc} across ${scoring.totalScored} rated titles.`);
69
73
  // Format preferences
70
74
  if (profile.formats.length > 0) {
@@ -93,13 +97,15 @@ function computeGenreWeights(entries) {
93
97
  return mapToSortedItems(genreMap);
94
98
  }
95
99
  /** Weight tags by user score multiplied by tag relevance */
96
- function computeTagWeights(entries) {
100
+ function computeTagWeights(entries, categoryFilter) {
97
101
  const tagMap = new Map();
98
102
  for (const entry of entries) {
99
103
  const scoreWeight = (entry.score / 10) * computeDecay(entry);
100
104
  for (const tag of entry.media.tags) {
101
105
  if (tag.isMediaSpoiler)
102
106
  continue;
107
+ if (categoryFilter && !tag.category.startsWith(categoryFilter))
108
+ continue;
103
109
  // Tag rank (0-100) indicates how relevant the tag is to this media
104
110
  const relevance = tag.rank / 100;
105
111
  const existing = tagMap.get(tag.name) ?? { weight: 0, count: 0 };
@@ -108,6 +114,11 @@ function computeTagWeights(entries) {
108
114
  tagMap.set(tag.name, existing);
109
115
  }
110
116
  }
117
+ // Filter noise (1-2 entries are never meaningful)
118
+ for (const [name, { count }] of tagMap) {
119
+ if (count < MIN_TAG_COUNT)
120
+ tagMap.delete(name);
121
+ }
111
122
  return mapToSortedItems(tagMap).slice(0, MAX_TAGS);
112
123
  }
113
124
  /** Score distribution and tendency classification */
@@ -126,10 +137,10 @@ function computeScoringPattern(entries) {
126
137
  }
127
138
  // Classify based on distance from site average (7.0)
128
139
  const tendency = mean >= SITE_MEAN + 0.5
129
- ? "generous"
140
+ ? "high"
130
141
  : mean <= SITE_MEAN - 0.5
131
- ? "harsh"
132
- : "average";
142
+ ? "low"
143
+ : "balanced";
133
144
  return {
134
145
  meanScore: mean,
135
146
  median,
@@ -146,25 +157,35 @@ function computeFormatBreakdown(entries) {
146
157
  const format = entry.media.format ?? "UNKNOWN";
147
158
  counts.set(format, (counts.get(format) ?? 0) + 1);
148
159
  }
149
- return [...counts.entries()]
150
- .map(([format, count]) => ({
160
+ // Largest-remainder method so percentages sum to 100
161
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
162
+ const rawPcts = sorted.map(([, c]) => (c / entries.length) * 100);
163
+ const floored = rawPcts.map(Math.floor);
164
+ let remainder = 100 - floored.reduce((a, b) => a + b, 0);
165
+ const remainders = rawPcts.map((v, i) => ({ i, r: v - floored[i] }));
166
+ remainders.sort((a, b) => b.r - a.r);
167
+ for (const { i } of remainders) {
168
+ if (remainder <= 0)
169
+ break;
170
+ floored[i] += 1;
171
+ remainder -= 1;
172
+ }
173
+ return sorted.map(([format, count], i) => ({
151
174
  format,
152
175
  count,
153
- percent: Math.round((count / entries.length) * 100),
154
- }))
155
- .sort((a, b) => b.count - a.count);
176
+ percent: floored[i],
177
+ }));
156
178
  }
157
179
  // === Helpers ===
158
- /** Convert a name->weight Map into a Bayesian-smoothed sorted array */
180
+ /** Convert a name->weight Map into a frequency-adjusted sorted array */
159
181
  function mapToSortedItems(map) {
160
182
  return [...map.entries()]
161
- .map(([name, { weight, count }]) => ({
162
- name,
163
- // Pull sparse observations toward a neutral prior
164
- weight: (weight + BAYESIAN_PRIOR_WEIGHT * BAYESIAN_PRIOR_COUNT) /
165
- (count + BAYESIAN_PRIOR_COUNT),
166
- count,
167
- }))
183
+ .map(([name, { weight, count }]) => {
184
+ // Bayesian average (quality) scaled by log frequency (prominence)
185
+ const avg = (weight + BAYESIAN_PRIOR_WEIGHT * BAYESIAN_PRIOR_COUNT) /
186
+ (count + BAYESIAN_PRIOR_COUNT);
187
+ return { name, weight: avg * Math.log2(count + 1), count };
188
+ })
168
189
  .sort((a, b) => b.weight - a.weight);
169
190
  }
170
191
  /** Recency multiplier (0-1) - recent entries weigh more than old ones */
@@ -213,12 +234,13 @@ function emptyProfile(totalCompleted) {
213
234
  return {
214
235
  genres: [],
215
236
  tags: [],
237
+ themes: [],
216
238
  scoring: {
217
239
  meanScore: 0,
218
240
  median: 0,
219
241
  totalScored: 0,
220
242
  distribution: {},
221
- tendency: "average",
243
+ tendency: "balanced",
222
244
  },
223
245
  formats: [],
224
246
  totalCompleted,
package/dist/index.js CHANGED
@@ -15,6 +15,18 @@ import { registerImportTools } from "./tools/import.js";
15
15
  import { registerCardTools } from "./tools/cards.js";
16
16
  import { registerResources } from "./resources.js";
17
17
  import { registerPrompts } from "./prompts.js";
18
+ // Sanitize env vars: clear unresolved templates, placeholders, or invalid values
19
+ for (const key of ["ANILIST_USERNAME", "ANILIST_TOKEN"]) {
20
+ const val = process.env[key] ?? "";
21
+ if (!val || val.startsWith("${") || val === "undefined" || val === "null") {
22
+ process.env[key] = "";
23
+ }
24
+ }
25
+ // AniList tokens are long JWT-like strings (100+ chars)
26
+ if (process.env.ANILIST_TOKEN && process.env.ANILIST_TOKEN.length < 30) {
27
+ console.warn("[ani-mcp] ANILIST_TOKEN looks invalid (too short), ignoring.");
28
+ process.env.ANILIST_TOKEN = "";
29
+ }
18
30
  // Both vars are optional - warn on missing so operators know what's available
19
31
  if (!process.env.ANILIST_USERNAME) {
20
32
  console.warn("ANILIST_USERNAME not set - tools will require a username argument.");
@@ -24,7 +36,13 @@ if (!process.env.ANILIST_TOKEN) {
24
36
  }
25
37
  const server = new FastMCP({
26
38
  name: "ani-mcp",
27
- version: "0.14.0",
39
+ version: "0.14.2",
40
+ instructions: "ani-mcp is a local MCP server for AniList. " +
41
+ "Read-only tools work without authentication. " +
42
+ "Write tools require ANILIST_TOKEN set in the server's environment config. " +
43
+ "If a tool says the token is not set, tell the user to add ANILIST_TOKEN " +
44
+ "to their MCP server config and restart. " +
45
+ "There is no in-app AniList integration or settings page to connect.",
28
46
  });
29
47
  registerSearchTools(server);
30
48
  registerListTools(server);
@@ -39,7 +39,7 @@ export function registerAnalyticsTools(server) {
39
39
  ];
40
40
  // Overall tendency
41
41
  const sign = result.overallDelta >= 0 ? "+" : "";
42
- lines.push(`Overall: ${sign}${result.overallDelta.toFixed(2)} vs community (${result.tendency} scorer)`);
42
+ lines.push(`Overall: ${sign}${result.overallDelta.toFixed(2)} vs community (${result.tendency} scorer vs avg)`);
43
43
  // Per-genre breakdown
44
44
  if (result.genreCalibrations.length > 0) {
45
45
  lines.push("", "Per-genre bias (biggest deviations first):");
@@ -200,10 +200,13 @@ export function registerAnalyticsTools(server) {
200
200
  let frontier = [...allIds];
201
201
  const maxRounds = 3;
202
202
  for (let round = 0; round < maxRounds && frontier.length > 0; round++) {
203
- // Process in chunks of 50
203
+ // Fetch all chunks in parallel (rate limiter queues excess)
204
+ const chunks = [];
204
205
  for (let i = 0; i < frontier.length; i += 50) {
205
- const chunk = frontier.slice(i, i + 50);
206
- const data = await anilistClient.query(BATCH_RELATIONS_QUERY, { ids: chunk }, { cache: "media" });
206
+ chunks.push(frontier.slice(i, i + 50));
207
+ }
208
+ const results = await Promise.all(chunks.map((chunk) => anilistClient.query(BATCH_RELATIONS_QUERY, { ids: chunk }, { cache: "media" })));
209
+ for (const data of results) {
207
210
  for (const media of data.Page.media) {
208
211
  if (!relationsMap.has(media.id)) {
209
212
  relationsMap.set(media.id, media);
@@ -26,7 +26,11 @@ export function registerCardTools(server) {
26
26
  },
27
27
  execute: async (args) => {
28
28
  const username = args.username ?? getDefaultUsername();
29
- const entries = await anilistClient.fetchList(username, args.type, "COMPLETED");
29
+ // Fetch list and avatar in parallel
30
+ const [entries, avatarUrl] = await Promise.all([
31
+ anilistClient.fetchList(username, args.type, "COMPLETED"),
32
+ getAvatarUrl(username),
33
+ ]);
30
34
  if (entries.length === 0) {
31
35
  return `${username} has no completed ${args.type.toLowerCase()}.`;
32
36
  }
@@ -42,8 +46,6 @@ export function registerCardTools(server) {
42
46
  throw new UserError(`${username} doesn't have enough scored titles to generate a card. ` +
43
47
  `Score more titles on AniList for a taste card.`);
44
48
  }
45
- // Fetch avatar in parallel with nothing else, but keep it non-blocking
46
- const avatarUrl = await getAvatarUrl(username);
47
49
  const avatarB64 = avatarUrl ? await fetchAvatarB64(avatarUrl) : null;
48
50
  const svg = buildTasteCardSvg(username, profile, avatarB64);
49
51
  const png = await svgToPng(svg);
@@ -136,7 +136,7 @@ export function registerDiscoverTools(server) {
136
136
  for (const tag of tags) {
137
137
  const cat = tag.category || "Other";
138
138
  if (args.category &&
139
- cat.toLowerCase() !== args.category.toLowerCase())
139
+ !cat.toLowerCase().startsWith(args.category.toLowerCase()))
140
140
  continue;
141
141
  const list = categories.get(cat);
142
142
  if (list) {
@@ -179,7 +179,7 @@ function kitsuEntriesToAniList(entries, animeMap) {
179
179
  studios: { nodes: [] },
180
180
  source: null,
181
181
  isAdult: false,
182
- coverImage: { large: null, extraLarge: null },
182
+ coverImage: { extraLarge: null },
183
183
  trailer: null,
184
184
  siteUrl: `https://kitsu.io/anime/${animeId}`,
185
185
  description: null,
@@ -224,13 +224,13 @@ export function registerInfoTools(server) {
224
224
  }
225
225
  // Extract media IDs for batch airing lookup
226
226
  const mediaIds = entries.map((e) => e.media.id);
227
- // Batch-fetch airing info (50 per page max)
228
- const airingMedia = [];
227
+ // Batch-fetch airing info in parallel (50 per page max)
228
+ const batches = [];
229
229
  for (let i = 0; i < mediaIds.length; i += 50) {
230
- const batch = mediaIds.slice(i, i + 50);
231
- const data = await anilistClient.query(BATCH_AIRING_QUERY, { ids: batch, perPage: 50 }, { cache: "schedule" });
232
- airingMedia.push(...data.Page.media);
230
+ batches.push(mediaIds.slice(i, i + 50));
233
231
  }
232
+ const airingResults = await Promise.all(batches.map((batch) => anilistClient.query(BATCH_AIRING_QUERY, { ids: batch, perPage: 50 }, { cache: "schedule" })));
233
+ const airingMedia = airingResults.flatMap((d) => d.Page.media);
234
234
  // Map media ID to user progress
235
235
  const progressMap = new Map(entries.map((e) => [e.media.id, e.progress]));
236
236
  // Sort by nearest airing time
@@ -1,9 +1,8 @@
1
1
  /** Recommendation tools: taste profiling, personalized picks, and user comparison. */
2
2
  import { anilistClient } from "../api/client.js";
3
- import { BATCH_RELATIONS_QUERY, COMPLETED_BY_DATE_QUERY, DISCOVER_MEDIA_QUERY, MEDIA_DETAILS_QUERY, RECOMMENDATIONS_QUERY, SEASONAL_MEDIA_QUERY, } from "../api/queries.js";
3
+ import { BATCH_RELATIONS_QUERY, COMPLETED_BY_DATE_QUERY, DISCOVER_MEDIA_QUERY, MEDIA_DETAILS_QUERY, RECOMMENDATIONS_QUERY, SEARCH_MEDIA_QUERY, SEASONAL_MEDIA_QUERY, } from "../api/queries.js";
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
- import { SEARCH_MEDIA_QUERY } from "../api/queries.js";
7
6
  import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "../engine/taste.js";
8
7
  import { matchCandidates, explainMatch } from "../engine/matcher.js";
9
8
  import { parseMood, hasMoodMatch, seasonalMoodSuggestions, } from "../engine/mood.js";
@@ -662,9 +661,9 @@ export function registerRecommendTools(server) {
662
661
  // Server-side date filter (FuzzyDateInt format: YYYYMMDD)
663
662
  const completedAfter = year * 10000 + 100 + 1; // Jan 1
664
663
  const completedBefore = year * 10000 + 1231; // Dec 31
665
- // Paginate through results
666
- const yearEntries = [];
667
- for (const type of types) {
664
+ // Paginate through results (types fetched in parallel)
665
+ async function fetchType(type) {
666
+ const results = [];
668
667
  let page = 1;
669
668
  let hasNext = true;
670
669
  while (hasNext) {
@@ -676,11 +675,13 @@ export function registerRecommendTools(server) {
676
675
  page,
677
676
  perPage: 50,
678
677
  }, { cache: "list" });
679
- yearEntries.push(...data.Page.mediaList);
678
+ results.push(...data.Page.mediaList);
680
679
  hasNext = data.Page.pageInfo.hasNextPage;
681
680
  page++;
682
681
  }
682
+ return results;
683
683
  }
684
+ const yearEntries = (await Promise.all(types.map(fetchType))).flat();
684
685
  if (yearEntries.length === 0) {
685
686
  return `${username} didn't complete any titles in ${year}.`;
686
687
  }
@@ -147,7 +147,7 @@ export function registerSearchTools(server) {
147
147
  }
148
148
  }
149
149
  // Cover image
150
- const cover = m.coverImage?.extraLarge ?? m.coverImage?.large;
150
+ const cover = m.coverImage?.extraLarge;
151
151
  if (cover)
152
152
  lines.push("", `Cover: ${cover}`);
153
153
  // Trailer
package/dist/types.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface AniListDate {
9
9
  export interface AniListTag {
10
10
  name: string;
11
11
  rank: number;
12
+ category: string;
12
13
  isMediaSpoiler: boolean;
13
14
  }
14
15
  /** Core media object shared across all query responses */
@@ -43,7 +44,6 @@ export interface AniListMedia {
43
44
  source: string | null;
44
45
  isAdult: boolean;
45
46
  coverImage: {
46
- large: string | null;
47
47
  extraLarge: string | null;
48
48
  };
49
49
  trailer: {
package/dist/utils.js CHANGED
@@ -130,7 +130,7 @@ export function formatMediaSummary(media) {
130
130
  if (studios)
131
131
  lines.push(` Studio: ${studios}`);
132
132
  // Best available cover image
133
- const cover = media.coverImage?.extraLarge ?? media.coverImage?.large;
133
+ const cover = media.coverImage?.extraLarge;
134
134
  if (cover)
135
135
  lines.push(` Cover: ${cover}`);
136
136
  // Trailer link
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ani-mcp",
4
- "version": "0.14.0",
4
+ "version": "0.14.2",
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": {
@@ -36,13 +36,13 @@
36
36
  "anilist_username": {
37
37
  "type": "string",
38
38
  "title": "AniList Username",
39
- "description": "Default AniList username for list and stats tools",
39
+ "description": "Optional. Your AniList username so tools don't need to ask each time.",
40
40
  "required": false
41
41
  },
42
42
  "anilist_token": {
43
43
  "type": "string",
44
44
  "title": "AniList Token",
45
- "description": "AniList OAuth token for write operations (update progress, rate, etc.)",
45
+ "description": "Optional. Your AniList API token for write features (rate, update progress, etc.).",
46
46
  "required": false,
47
47
  "sensitive": true
48
48
  }
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.14.0",
4
+ "version": "0.14.2",
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.14.0",
9
+ "version": "0.14.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ani-mcp",
14
- "version": "0.14.0",
14
+ "version": "0.14.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },