ani-mcp 0.2.5 → 0.4.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
@@ -4,6 +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)
8
+ [![CI](https://github.com/gavxm/ani-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/gavxm/ani-mcp/actions/workflows/ci.yml)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
10
+ [![Node](https://img.shields.io/node/v/ani-mcp)](https://nodejs.org)
11
+
7
12
  A smart [MCP](https://modelcontextprotocol.io) server for [AniList](https://anilist.co) that understands your anime and manga taste - not just raw API calls.
8
13
 
9
14
  ## What makes this different
@@ -43,6 +48,9 @@ Works with any MCP-compatible client.
43
48
  | --- | --- | --- |
44
49
  | `ANILIST_USERNAME` | No | Default username for list and stats tools. Can also pass per-call. |
45
50
  | `ANILIST_TOKEN` | No | AniList OAuth token. Required for write operations and private lists. |
51
+ | `ANILIST_TITLE_LANGUAGE` | No | Title preference: `english` (default), `romaji`, or `native`. |
52
+ | `ANILIST_SCORE_FORMAT` | No | Override score display: `POINT_100`, `POINT_10_DECIMAL`, `POINT_10`, `POINT_5`, `POINT_3`. |
53
+ | `ANILIST_NSFW` | No | Set to `true` to include adult content in results. Default: `false`. |
46
54
  | `DEBUG` | No | Set to `true` for debug logging to stderr. |
47
55
  | `MCP_TRANSPORT` | No | Set to `http` for HTTP Stream transport. Default: stdio. |
48
56
  | `MCP_PORT` | No | Port for HTTP transport. Default: `3000`. |
@@ -59,6 +67,7 @@ Works with any MCP-compatible client.
59
67
  | `anilist_seasonal` | Browse a season's anime lineup |
60
68
  | `anilist_trending` | What's trending on AniList right now |
61
69
  | `anilist_genres` | Browse top titles in a genre with optional filters |
70
+ | `anilist_genre_list` | List all valid genres and content tags |
62
71
  | `anilist_recommendations` | Community recommendations for a specific title |
63
72
 
64
73
  ### Lists & Stats
@@ -88,6 +97,7 @@ Works with any MCP-compatible client.
88
97
  | `anilist_studio_search` | Search for a studio and see their productions |
89
98
  | `anilist_schedule` | Airing schedule and next episode countdown |
90
99
  | `anilist_characters` | Search characters by name with appearances and VAs |
100
+ | `anilist_whoami` | Check authentication status and score format |
91
101
 
92
102
  ### Write (requires `ANILIST_TOKEN`)
93
103
 
@@ -4,7 +4,7 @@
4
4
  * Handles rate limiting (token bucket), retry with exponential backoff,
5
5
  * and in-memory caching.
6
6
  */
7
- import type { AniListMediaListEntry } from "../types.js";
7
+ import type { AniListMediaListEntry, UserListResponse } from "../types.js";
8
8
  /** Per-category TTLs for the query cache */
9
9
  export declare const CACHE_TTLS: {
10
10
  readonly media: number;
@@ -31,6 +31,8 @@ declare class AniListClient {
31
31
  constructor();
32
32
  /** Execute a GraphQL query with caching and automatic retry */
33
33
  query<T = unknown>(query: string, variables?: Record<string, unknown>, options?: QueryOptions): Promise<T>;
34
+ /** Fetch a user's media list groups with metadata (name, status, isCustomList) */
35
+ fetchListGroups(username: string, type: string, status?: string, sort?: string[]): Promise<UserListResponse["MediaListCollection"]["lists"]>;
34
36
  /** Fetch a user's media list, flattened into a single array */
35
37
  fetchList(username: string, type: string, status?: string, sort?: string[]): Promise<AniListMediaListEntry[]>;
36
38
  /** Invalidate the entire query cache */
@@ -87,17 +87,22 @@ class AniListClient {
87
87
  // No cache category - skip caching entirely
88
88
  return this.executeWithRetry(query, variables);
89
89
  }
90
- /** Fetch a user's media list, flattened into a single array */
91
- async fetchList(username, type, status, sort) {
90
+ /** Fetch a user's media list groups with metadata (name, status, isCustomList) */
91
+ async fetchListGroups(username, type, status, sort) {
92
92
  const variables = { userName: username, type };
93
93
  if (status)
94
94
  variables.status = status;
95
95
  if (sort)
96
96
  variables.sort = sort;
97
97
  const data = await this.query(USER_LIST_QUERY, variables, { cache: "list" });
98
+ return data.MediaListCollection.lists;
99
+ }
100
+ /** Fetch a user's media list, flattened into a single array */
101
+ async fetchList(username, type, status, sort) {
102
+ const lists = await this.fetchListGroups(username, type, status, sort);
98
103
  // Flatten across status groups
99
104
  const entries = [];
100
- for (const list of data.MediaListCollection.lists) {
105
+ for (const list of lists) {
101
106
  entries.push(...list.entries);
102
107
  }
103
108
  return entries;
@@ -143,25 +148,29 @@ class AniListClient {
143
148
  catch (error) {
144
149
  const msg = error instanceof Error ? error.message : String(error);
145
150
  log("network-error", msg);
146
- throw new AniListApiError(`Network error connecting to AniList: ${msg}`, undefined, true);
151
+ const isTimeout = msg.includes("abort") || msg.includes("timeout");
152
+ throw new AniListApiError(isTimeout
153
+ ? "Could not reach AniList (request timed out). Try again."
154
+ : `Network error connecting to AniList: ${msg}`, undefined, true);
147
155
  }
148
156
  // Map HTTP errors to retryable/non-retryable
149
157
  if (!response.ok) {
150
158
  // Read error body for context
151
159
  const body = await response.text().catch(() => "");
152
160
  if (response.status === 429) {
153
- log("rate-limit", `429 from AniList`);
154
161
  const retryAfter = response.headers.get("Retry-After");
155
- if (retryAfter) {
156
- const delaySec = parseInt(retryAfter, 10);
157
- if (delaySec > 0) {
158
- await new Promise((r) => setTimeout(r, delaySec * 1000));
159
- }
162
+ const delaySec = retryAfter ? parseInt(retryAfter, 10) : 0;
163
+ log("rate-limit", `429 from AniList (retry-after: ${delaySec || "none"})`);
164
+ if (delaySec > 0) {
165
+ await new Promise((r) => setTimeout(r, delaySec * 1000));
160
166
  }
161
- throw new AniListApiError("AniList rate limit hit. The server will retry automatically.", 429, true);
167
+ throw new AniListApiError(`AniList rate limit exceeded. Try again in ${delaySec > 0 ? `${delaySec} seconds` : "30-60 seconds"}.`, 429, true);
168
+ }
169
+ if (response.status === 401) {
170
+ throw new AbortError(new AniListApiError("Authentication failed. Check that ANILIST_TOKEN is valid and not expired.", 401, false));
162
171
  }
163
172
  if (response.status === 404) {
164
- throw new AbortError(new AniListApiError("Resource not found on AniList. Check that the ID or username is correct.", 404, false));
173
+ throw new AbortError(new AniListApiError("Not found on AniList. Check that the ID or username is correct.", 404, false));
165
174
  }
166
175
  // Only server errors (5xx) are worth retrying
167
176
  if (response.status >= 500) {
@@ -13,7 +13,7 @@ export declare const DISCOVER_MEDIA_QUERY = "\n query DiscoverMedia(\n $type
13
13
  /** Browse anime by season and year */
14
14
  export declare const SEASONAL_MEDIA_QUERY = "\n query SeasonalMedia(\n $season: MediaSeason\n $seasonYear: Int\n $type: MediaType\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage lastPage hasNextPage }\n media(\n season: $season\n seasonYear: $seasonYear\n type: $type\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
15
15
  /** User profile statistics - watching/reading stats, genre/tag/score breakdowns */
16
- export declare const USER_STATS_QUERY = "\n query UserStats($name: String!) {\n User(name: $name) {\n id\n name\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";
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
18
  export declare const RECOMMENDATIONS_QUERY = "\n query MediaRecommendations($id: Int, $search: String, $perPage: Int) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n recommendations(sort: RATING_DESC, perPage: $perPage) {\n nodes {\n rating\n mediaRecommendation {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
19
19
  /** Trending anime or manga right now */
@@ -27,12 +27,26 @@ export declare const AIRING_SCHEDULE_QUERY = "\n query AiringSchedule($id: Int,
27
27
  /** Search for characters by name */
28
28
  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
29
  /** 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 $score: Float\n $progress: Int\n $notes: String\n $private: Boolean\n ) {\n SaveMediaListEntry(\n mediaId: $mediaId\n status: $status\n score: $score\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";
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";
31
31
  /** Remove a list entry */
32
32
  export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
33
33
  /** User's anime/manga list, grouped by status. Omit $status to get all lists. */
34
- export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
34
+ export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n isCustomList\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
35
35
  /** Search for staff by name with their top works */
36
36
  export declare const STAFF_SEARCH_QUERY = "\n query StaffSearch($search: String!, $page: Int, $perPage: Int, $mediaPerPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n staff(search: $search, sort: SEARCH_MATCH) {\n id\n name { full native }\n primaryOccupations\n siteUrl\n staffMedia(sort: POPULARITY_DESC, perPage: $mediaPerPage) {\n edges {\n staffRole\n node {\n id\n title { romaji english }\n format\n type\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n }\n";
37
+ /** Authenticated user info */
38
+ export declare const VIEWER_QUERY = "\n query Viewer {\n Viewer {\n id\n name\n avatar { medium }\n siteUrl\n mediaListOptions {\n scoreFormat\n }\n }\n }\n";
39
+ /** All valid genres and media tags */
40
+ export declare const GENRE_TAG_COLLECTION_QUERY = "\n query GenreTagCollection {\n GenreCollection\n MediaTagCollection {\n name\n description\n category\n isAdult\n }\n }\n";
41
+ /** Toggle favourite on any entity type */
42
+ export declare const TOGGLE_FAVOURITE_MUTATION = "\n mutation ToggleFavourite(\n $animeId: Int\n $mangaId: Int\n $characterId: Int\n $staffId: Int\n $studioId: Int\n ) {\n ToggleFavourite(\n animeId: $animeId\n mangaId: $mangaId\n characterId: $characterId\n staffId: $staffId\n studioId: $studioId\n ) {\n anime { nodes { id } }\n manga { nodes { id } }\n characters { nodes { id } }\n staff { nodes { id } }\n studios { nodes { id } }\n }\n }\n";
43
+ /** Post a text activity to the authenticated user's feed */
44
+ export declare const SAVE_TEXT_ACTIVITY_MUTATION = "\n mutation SaveTextActivity($text: String!) {\n SaveTextActivity(text: $text) {\n id\n createdAt\n text\n user { name }\n }\n }\n";
45
+ /** Recent activity for a user, supports text and list activity types */
46
+ export declare const ACTIVITY_FEED_QUERY = "\n query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage hasNextPage }\n activities(userId: $userId, type: $type, sort: ID_DESC) {\n ... on TextActivity {\n __typename\n id\n text\n createdAt\n user { name }\n }\n ... on ListActivity {\n __typename\n id\n status\n progress\n createdAt\n user { name }\n media {\n id\n title { romaji english native }\n type\n }\n }\n }\n }\n }\n";
47
+ /** User profile with bio, stats summary, and top favourites */
48
+ export declare const USER_PROFILE_QUERY = "\n query UserProfile($name: String) {\n User(name: $name) {\n id\n name\n about\n avatar { large }\n bannerImage\n siteUrl\n createdAt\n updatedAt\n donatorTier\n statistics {\n anime {\n count\n meanScore\n episodesWatched\n minutesWatched\n }\n manga {\n count\n meanScore\n chaptersRead\n volumesRead\n }\n }\n favourites {\n anime(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n manga(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n characters(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n staff(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n studios(perPage: 5) {\n nodes { id name siteUrl }\n }\n }\n }\n }\n";
49
+ /** Community reviews for a media title */
50
+ export declare const MEDIA_REVIEWS_QUERY = "\n query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n reviews(page: $page, perPage: $perPage, sort: $sort) {\n pageInfo { total hasNextPage }\n nodes {\n id\n score\n summary\n body\n rating\n ratingAmount\n createdAt\n user { name siteUrl }\n }\n }\n }\n }\n";
37
51
  /** Search for a studio by name with their productions */
38
52
  export declare const STUDIO_SEARCH_QUERY = "\n query StudioSearch($search: String!, $perPage: Int) {\n Studio(search: $search, sort: SEARCH_MATCH) {\n id\n name\n isAnimationStudio\n siteUrl\n media(sort: POPULARITY_DESC, perPage: $perPage) {\n edges {\n isMainStudio\n node {\n id\n title { romaji english }\n format\n type\n status\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n";
@@ -161,6 +161,9 @@ export const USER_STATS_QUERY = `
161
161
  User(name: $name) {
162
162
  id
163
163
  name
164
+ mediaListOptions {
165
+ scoreFormat
166
+ }
164
167
  statistics {
165
168
  anime {
166
169
  count
@@ -369,7 +372,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
369
372
  mutation SaveMediaListEntry(
370
373
  $mediaId: Int
371
374
  $status: MediaListStatus
372
- $score: Float
375
+ $scoreRaw: Int
373
376
  $progress: Int
374
377
  $notes: String
375
378
  $private: Boolean
@@ -377,7 +380,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
377
380
  SaveMediaListEntry(
378
381
  mediaId: $mediaId
379
382
  status: $status
380
- score: $score
383
+ scoreRaw: $scoreRaw
381
384
  progress: $progress
382
385
  notes: $notes
383
386
  private: $private
@@ -415,6 +418,7 @@ export const USER_LIST_QUERY = `
415
418
  lists {
416
419
  name
417
420
  status
421
+ isCustomList
418
422
  entries {
419
423
  id
420
424
  score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting
@@ -460,6 +464,167 @@ export const STAFF_SEARCH_QUERY = `
460
464
  }
461
465
  }
462
466
  `;
467
+ /** Authenticated user info */
468
+ export const VIEWER_QUERY = `
469
+ query Viewer {
470
+ Viewer {
471
+ id
472
+ name
473
+ avatar { medium }
474
+ siteUrl
475
+ mediaListOptions {
476
+ scoreFormat
477
+ }
478
+ }
479
+ }
480
+ `;
481
+ /** All valid genres and media tags */
482
+ export const GENRE_TAG_COLLECTION_QUERY = `
483
+ query GenreTagCollection {
484
+ GenreCollection
485
+ MediaTagCollection {
486
+ name
487
+ description
488
+ category
489
+ isAdult
490
+ }
491
+ }
492
+ `;
493
+ // === 0.4.0 Social & Favourites ===
494
+ /** Toggle favourite on any entity type */
495
+ export const TOGGLE_FAVOURITE_MUTATION = `
496
+ mutation ToggleFavourite(
497
+ $animeId: Int
498
+ $mangaId: Int
499
+ $characterId: Int
500
+ $staffId: Int
501
+ $studioId: Int
502
+ ) {
503
+ ToggleFavourite(
504
+ animeId: $animeId
505
+ mangaId: $mangaId
506
+ characterId: $characterId
507
+ staffId: $staffId
508
+ studioId: $studioId
509
+ ) {
510
+ anime { nodes { id } }
511
+ manga { nodes { id } }
512
+ characters { nodes { id } }
513
+ staff { nodes { id } }
514
+ studios { nodes { id } }
515
+ }
516
+ }
517
+ `;
518
+ /** Post a text activity to the authenticated user's feed */
519
+ export const SAVE_TEXT_ACTIVITY_MUTATION = `
520
+ mutation SaveTextActivity($text: String!) {
521
+ SaveTextActivity(text: $text) {
522
+ id
523
+ createdAt
524
+ text
525
+ user { name }
526
+ }
527
+ }
528
+ `;
529
+ /** Recent activity for a user, supports text and list activity types */
530
+ export const ACTIVITY_FEED_QUERY = `
531
+ query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {
532
+ Page(page: $page, perPage: $perPage) {
533
+ pageInfo { total currentPage hasNextPage }
534
+ activities(userId: $userId, type: $type, sort: ID_DESC) {
535
+ ... on TextActivity {
536
+ __typename
537
+ id
538
+ text
539
+ createdAt
540
+ user { name }
541
+ }
542
+ ... on ListActivity {
543
+ __typename
544
+ id
545
+ status
546
+ progress
547
+ createdAt
548
+ user { name }
549
+ media {
550
+ id
551
+ title { romaji english native }
552
+ type
553
+ }
554
+ }
555
+ }
556
+ }
557
+ }
558
+ `;
559
+ /** User profile with bio, stats summary, and top favourites */
560
+ export const USER_PROFILE_QUERY = `
561
+ query UserProfile($name: String) {
562
+ User(name: $name) {
563
+ id
564
+ name
565
+ about
566
+ avatar { large }
567
+ bannerImage
568
+ siteUrl
569
+ createdAt
570
+ updatedAt
571
+ donatorTier
572
+ statistics {
573
+ anime {
574
+ count
575
+ meanScore
576
+ episodesWatched
577
+ minutesWatched
578
+ }
579
+ manga {
580
+ count
581
+ meanScore
582
+ chaptersRead
583
+ volumesRead
584
+ }
585
+ }
586
+ favourites {
587
+ anime(perPage: 5) {
588
+ nodes { id title { romaji english native } siteUrl }
589
+ }
590
+ manga(perPage: 5) {
591
+ nodes { id title { romaji english native } siteUrl }
592
+ }
593
+ characters(perPage: 5) {
594
+ nodes { id name { full } siteUrl }
595
+ }
596
+ staff(perPage: 5) {
597
+ nodes { id name { full } siteUrl }
598
+ }
599
+ studios(perPage: 5) {
600
+ nodes { id name siteUrl }
601
+ }
602
+ }
603
+ }
604
+ }
605
+ `;
606
+ /** Community reviews for a media title */
607
+ export const MEDIA_REVIEWS_QUERY = `
608
+ query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {
609
+ Media(id: $id, search: $search) {
610
+ id
611
+ title { romaji english native }
612
+ reviews(page: $page, perPage: $perPage, sort: $sort) {
613
+ pageInfo { total hasNextPage }
614
+ nodes {
615
+ id
616
+ score
617
+ summary
618
+ body
619
+ rating
620
+ ratingAmount
621
+ createdAt
622
+ user { name siteUrl }
623
+ }
624
+ }
625
+ }
626
+ }
627
+ `;
463
628
  /** Search for a studio by name with their productions */
464
629
  export const STUDIO_SEARCH_QUERY = `
465
630
  query StudioSearch($search: String!, $perPage: Int) {
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { registerRecommendTools } from "./tools/recommend.js";
8
8
  import { registerDiscoverTools } from "./tools/discover.js";
9
9
  import { registerInfoTools } from "./tools/info.js";
10
10
  import { registerWriteTools } from "./tools/write.js";
11
+ import { registerSocialTools } from "./tools/social.js";
11
12
  // Both vars are optional - warn on missing so operators know what's available
12
13
  if (!process.env.ANILIST_USERNAME) {
13
14
  console.warn("ANILIST_USERNAME not set - tools will require a username argument.");
@@ -17,7 +18,7 @@ if (!process.env.ANILIST_TOKEN) {
17
18
  }
18
19
  const server = new FastMCP({
19
20
  name: "ani-mcp",
20
- version: "0.1.0",
21
+ version: "0.4.0",
21
22
  });
22
23
  registerSearchTools(server);
23
24
  registerListTools(server);
@@ -25,6 +26,7 @@ registerRecommendTools(server);
25
26
  registerDiscoverTools(server);
26
27
  registerInfoTools(server);
27
28
  registerWriteTools(server);
29
+ registerSocialTools(server);
28
30
  // === Transport ===
29
31
  const transport = process.env.MCP_TRANSPORT === "http" ? "httpStream" : "stdio";
30
32
  if (transport === "httpStream") {
package/dist/schemas.d.ts CHANGED
@@ -44,7 +44,9 @@ export declare const ListInputSchema: z.ZodObject<{
44
44
  DROPPED: "DROPPED";
45
45
  PAUSED: "PAUSED";
46
46
  ALL: "ALL";
47
+ CUSTOM: "CUSTOM";
47
48
  }>>;
49
+ customListName: z.ZodOptional<z.ZodString>;
48
50
  sort: z.ZodDefault<z.ZodEnum<{
49
51
  SCORE: "SCORE";
50
52
  TITLE: "TITLE";
@@ -257,9 +259,61 @@ export declare const StaffSearchInputSchema: z.ZodObject<{
257
259
  page: z.ZodDefault<z.ZodNumber>;
258
260
  }, z.core.$strip>;
259
261
  export type StaffSearchInput = z.infer<typeof StaffSearchInputSchema>;
262
+ /** Input for listing all valid genres and tags */
263
+ export declare const GenreListInputSchema: z.ZodObject<{
264
+ includeAdultTags: z.ZodDefault<z.ZodBoolean>;
265
+ }, z.core.$strip>;
266
+ export type GenreListInput = z.infer<typeof GenreListInputSchema>;
260
267
  /** Input for searching studios by name */
261
268
  export declare const StudioSearchInputSchema: z.ZodObject<{
262
269
  query: z.ZodString;
263
270
  limit: z.ZodDefault<z.ZodNumber>;
264
271
  }, z.core.$strip>;
265
272
  export type StudioSearchInput = z.infer<typeof StudioSearchInputSchema>;
273
+ /** Input for toggling a favourite */
274
+ export declare const FavouriteInputSchema: z.ZodObject<{
275
+ type: z.ZodEnum<{
276
+ ANIME: "ANIME";
277
+ MANGA: "MANGA";
278
+ CHARACTER: "CHARACTER";
279
+ STAFF: "STAFF";
280
+ STUDIO: "STUDIO";
281
+ }>;
282
+ id: z.ZodNumber;
283
+ }, z.core.$strip>;
284
+ export type FavouriteInput = z.infer<typeof FavouriteInputSchema>;
285
+ /** Input for posting a text activity */
286
+ export declare const PostActivityInputSchema: z.ZodObject<{
287
+ text: z.ZodString;
288
+ }, z.core.$strip>;
289
+ export type PostActivityInput = z.infer<typeof PostActivityInputSchema>;
290
+ /** Input for fetching a user's activity feed */
291
+ export declare const FeedInputSchema: z.ZodObject<{
292
+ username: z.ZodOptional<z.ZodString>;
293
+ type: z.ZodDefault<z.ZodEnum<{
294
+ ALL: "ALL";
295
+ TEXT: "TEXT";
296
+ ANIME_LIST: "ANIME_LIST";
297
+ MANGA_LIST: "MANGA_LIST";
298
+ }>>;
299
+ limit: z.ZodDefault<z.ZodNumber>;
300
+ page: z.ZodDefault<z.ZodNumber>;
301
+ }, z.core.$strip>;
302
+ export type FeedInput = z.infer<typeof FeedInputSchema>;
303
+ /** Input for viewing a user's profile */
304
+ export declare const ProfileInputSchema: z.ZodObject<{
305
+ username: z.ZodOptional<z.ZodString>;
306
+ }, z.core.$strip>;
307
+ export type ProfileInput = z.infer<typeof ProfileInputSchema>;
308
+ /** Input for fetching community reviews for a title */
309
+ export declare const ReviewsInputSchema: z.ZodObject<{
310
+ id: z.ZodOptional<z.ZodNumber>;
311
+ title: z.ZodOptional<z.ZodString>;
312
+ sort: z.ZodDefault<z.ZodEnum<{
313
+ HELPFUL: "HELPFUL";
314
+ NEWEST: "NEWEST";
315
+ }>>;
316
+ limit: z.ZodDefault<z.ZodNumber>;
317
+ page: z.ZodDefault<z.ZodNumber>;
318
+ }, z.core.$strip>;
319
+ export type ReviewsInput = z.infer<typeof ReviewsInputSchema>;
package/dist/schemas.js CHANGED
@@ -88,9 +88,13 @@ export const ListInputSchema = z.object({
88
88
  .default("ANIME")
89
89
  .describe("Get anime or manga list"),
90
90
  status: z
91
- .enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED", "ALL"])
91
+ .enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED", "ALL", "CUSTOM"])
92
92
  .default("ALL")
93
- .describe("Filter by list status. CURRENT = watching/reading now."),
93
+ .describe("Filter by list status. CURRENT = watching/reading now. CUSTOM = user-created lists."),
94
+ customListName: z
95
+ .string()
96
+ .optional()
97
+ .describe("Filter to a specific custom list by name. Only used when status is CUSTOM."),
94
98
  sort: z
95
99
  .enum(["SCORE", "TITLE", "UPDATED", "PROGRESS"])
96
100
  .default("UPDATED")
@@ -442,6 +446,13 @@ export const StaffSearchInputSchema = z.object({
442
446
  .describe("Works per person to show (default 10, max 25)"),
443
447
  page: pageParam,
444
448
  });
449
+ /** Input for listing all valid genres and tags */
450
+ export const GenreListInputSchema = z.object({
451
+ includeAdultTags: z
452
+ .boolean()
453
+ .default(false)
454
+ .describe("Include adult/NSFW tags in the list"),
455
+ });
445
456
  /** Input for searching studios by name */
446
457
  export const StudioSearchInputSchema = z.object({
447
458
  query: z
@@ -456,3 +467,76 @@ export const StudioSearchInputSchema = z.object({
456
467
  .default(10)
457
468
  .describe("Number of works to show (default 10, max 25)"),
458
469
  });
470
+ // === 0.4.0 Social & Favourites ===
471
+ /** Input for toggling a favourite */
472
+ export const FavouriteInputSchema = z.object({
473
+ type: z
474
+ .enum(["ANIME", "MANGA", "CHARACTER", "STAFF", "STUDIO"])
475
+ .describe("Type of entity to favourite"),
476
+ id: z
477
+ .number()
478
+ .int()
479
+ .positive()
480
+ .describe("AniList ID of the entity to toggle favourite on"),
481
+ });
482
+ /** Input for posting a text activity */
483
+ export const PostActivityInputSchema = z.object({
484
+ text: z
485
+ .string()
486
+ .min(1)
487
+ .max(2000)
488
+ .describe("Text content of the activity post"),
489
+ });
490
+ /** Input for fetching a user's activity feed */
491
+ export const FeedInputSchema = z.object({
492
+ username: usernameSchema
493
+ .optional()
494
+ .describe("AniList username. Falls back to configured default if not provided."),
495
+ type: z
496
+ .enum(["TEXT", "ANIME_LIST", "MANGA_LIST", "ALL"])
497
+ .default("ALL")
498
+ .describe("Filter by activity type"),
499
+ limit: z
500
+ .number()
501
+ .int()
502
+ .min(1)
503
+ .max(25)
504
+ .default(10)
505
+ .describe("Number of activities to return (default 10, max 25)"),
506
+ page: pageParam,
507
+ });
508
+ /** Input for viewing a user's profile */
509
+ export const ProfileInputSchema = z.object({
510
+ username: usernameSchema
511
+ .optional()
512
+ .describe("AniList username. Falls back to configured default if not provided."),
513
+ });
514
+ /** Input for fetching community reviews for a title */
515
+ export const ReviewsInputSchema = z
516
+ .object({
517
+ id: z
518
+ .number()
519
+ .int()
520
+ .positive()
521
+ .optional()
522
+ .describe("AniList media ID"),
523
+ title: z
524
+ .string()
525
+ .optional()
526
+ .describe("Search by title if no ID is known"),
527
+ sort: z
528
+ .enum(["HELPFUL", "NEWEST"])
529
+ .default("HELPFUL")
530
+ .describe("Sort by most helpful or newest"),
531
+ limit: z
532
+ .number()
533
+ .int()
534
+ .min(1)
535
+ .max(10)
536
+ .default(5)
537
+ .describe("Number of reviews to return (default 5, max 10)"),
538
+ page: pageParam,
539
+ })
540
+ .refine((data) => data.id !== undefined || data.title !== undefined, {
541
+ message: "Provide either an id or a title.",
542
+ });
@@ -1,8 +1,8 @@
1
1
  /** Discovery tools: trending and genre browsing without search terms. */
2
2
  import { anilistClient } from "../api/client.js";
3
- import { TRENDING_MEDIA_QUERY, GENRE_BROWSE_QUERY } from "../api/queries.js";
4
- import { TrendingInputSchema, GenreBrowseInputSchema } from "../schemas.js";
5
- import { formatMediaSummary, throwToolError, paginationFooter } from "../utils.js";
3
+ import { TRENDING_MEDIA_QUERY, GENRE_BROWSE_QUERY, GENRE_TAG_COLLECTION_QUERY, } from "../api/queries.js";
4
+ import { TrendingInputSchema, GenreBrowseInputSchema, GenreListInputSchema, } from "../schemas.js";
5
+ import { formatMediaSummary, throwToolError, paginationFooter, } from "../utils.js";
6
6
  /** Register discovery tools on the MCP server */
7
7
  export function registerDiscoverTools(server) {
8
8
  // === Trending ===
@@ -38,7 +38,7 @@ export function registerDiscoverTools(server) {
38
38
  ].join("\n");
39
39
  const formatted = results.map((m, i) => `${offset + i + 1}. ${formatMediaSummary(m)}`);
40
40
  const footer = paginationFooter(args.page, args.limit, pageInfo.total, pageInfo.hasNextPage);
41
- return header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : "");
41
+ return (header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : ""));
42
42
  }
43
43
  catch (error) {
44
44
  return throwToolError(error, "fetching trending");
@@ -102,11 +102,62 @@ export function registerDiscoverTools(server) {
102
102
  ].join("\n");
103
103
  const formatted = results.map((m, i) => `${offset + i + 1}. ${formatMediaSummary(m)}`);
104
104
  const footer = paginationFooter(args.page, args.limit, pageInfo.total, pageInfo.hasNextPage);
105
- return header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : "");
105
+ return (header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : ""));
106
106
  }
107
107
  catch (error) {
108
108
  return throwToolError(error, "browsing genres");
109
109
  }
110
110
  },
111
111
  });
112
+ // === Genre/Tag List ===
113
+ server.addTool({
114
+ name: "anilist_genre_list",
115
+ description: "List all valid AniList genres and content tags. " +
116
+ "Use when the user asks what genres exist, wants to see available tags, " +
117
+ "or needs valid genre names for filtering other tools.",
118
+ parameters: GenreListInputSchema,
119
+ annotations: {
120
+ title: "List Genres & Tags",
121
+ readOnlyHint: true,
122
+ destructiveHint: false,
123
+ openWorldHint: true,
124
+ },
125
+ execute: async (args) => {
126
+ try {
127
+ const data = await anilistClient.query(GENRE_TAG_COLLECTION_QUERY, {}, { cache: "media" });
128
+ const lines = [
129
+ "# AniList Genres",
130
+ "",
131
+ data.GenreCollection.join(", "),
132
+ ];
133
+ // Group tags by category
134
+ let tags = data.MediaTagCollection;
135
+ if (!args.includeAdultTags) {
136
+ tags = tags.filter((t) => !t.isAdult);
137
+ }
138
+ const categories = new Map();
139
+ for (const tag of tags) {
140
+ const cat = tag.category || "Other";
141
+ const list = categories.get(cat);
142
+ if (list) {
143
+ list.push(tag);
144
+ }
145
+ else {
146
+ categories.set(cat, [tag]);
147
+ }
148
+ }
149
+ lines.push("", "# Content Tags");
150
+ for (const [category, catTags] of categories) {
151
+ lines.push("", `## ${category}`);
152
+ for (const tag of catTags) {
153
+ lines.push(` ${tag.name} - ${tag.description}`);
154
+ }
155
+ }
156
+ return lines.join("\n");
157
+ }
158
+ catch (error) {
159
+ return throwToolError(error, "fetching genre list");
160
+ }
161
+ },
162
+ });
112
163
  }