ani-mcp 0.2.4 → 0.3.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
 
@@ -143,25 +143,29 @@ class AniListClient {
143
143
  catch (error) {
144
144
  const msg = error instanceof Error ? error.message : String(error);
145
145
  log("network-error", msg);
146
- throw new AniListApiError(`Network error connecting to AniList: ${msg}`, undefined, true);
146
+ const isTimeout = msg.includes("abort") || msg.includes("timeout");
147
+ throw new AniListApiError(isTimeout
148
+ ? "Could not reach AniList (request timed out). Try again."
149
+ : `Network error connecting to AniList: ${msg}`, undefined, true);
147
150
  }
148
151
  // Map HTTP errors to retryable/non-retryable
149
152
  if (!response.ok) {
150
153
  // Read error body for context
151
154
  const body = await response.text().catch(() => "");
152
155
  if (response.status === 429) {
153
- log("rate-limit", `429 from AniList`);
154
156
  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
- }
157
+ const delaySec = retryAfter ? parseInt(retryAfter, 10) : 0;
158
+ log("rate-limit", `429 from AniList (retry-after: ${delaySec || "none"})`);
159
+ if (delaySec > 0) {
160
+ await new Promise((r) => setTimeout(r, delaySec * 1000));
160
161
  }
161
- throw new AniListApiError("AniList rate limit hit. The server will retry automatically.", 429, true);
162
+ throw new AniListApiError(`AniList rate limit exceeded. Try again in ${delaySec > 0 ? `${delaySec} seconds` : "30-60 seconds"}.`, 429, true);
163
+ }
164
+ if (response.status === 401) {
165
+ throw new AbortError(new AniListApiError("Authentication failed. Check that ANILIST_TOKEN is valid and not expired.", 401, false));
162
166
  }
163
167
  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));
168
+ throw new AbortError(new AniListApiError("Not found on AniList. Check that the ID or username is correct.", 404, false));
165
169
  }
166
170
  // Only server errors (5xx) are worth retrying
167
171
  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,16 @@ 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
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";
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";
37
41
  /** Search for a studio by name with their productions */
38
42
  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
@@ -460,6 +463,32 @@ export const STAFF_SEARCH_QUERY = `
460
463
  }
461
464
  }
462
465
  `;
466
+ /** Authenticated user info */
467
+ export const VIEWER_QUERY = `
468
+ query Viewer {
469
+ Viewer {
470
+ id
471
+ name
472
+ avatar { medium }
473
+ siteUrl
474
+ mediaListOptions {
475
+ scoreFormat
476
+ }
477
+ }
478
+ }
479
+ `;
480
+ /** All valid genres and media tags */
481
+ export const GENRE_TAG_COLLECTION_QUERY = `
482
+ query GenreTagCollection {
483
+ GenreCollection
484
+ MediaTagCollection {
485
+ name
486
+ description
487
+ category
488
+ isAdult
489
+ }
490
+ }
491
+ `;
463
492
  /** Search for a studio by name with their productions */
464
493
  export const STUDIO_SEARCH_QUERY = `
465
494
  query StudioSearch($search: String!, $perPage: Int) {
package/dist/schemas.d.ts CHANGED
@@ -257,6 +257,11 @@ export declare const StaffSearchInputSchema: z.ZodObject<{
257
257
  page: z.ZodDefault<z.ZodNumber>;
258
258
  }, z.core.$strip>;
259
259
  export type StaffSearchInput = z.infer<typeof StaffSearchInputSchema>;
260
+ /** Input for listing all valid genres and tags */
261
+ export declare const GenreListInputSchema: z.ZodObject<{
262
+ includeAdultTags: z.ZodDefault<z.ZodBoolean>;
263
+ }, z.core.$strip>;
264
+ export type GenreListInput = z.infer<typeof GenreListInputSchema>;
260
265
  /** Input for searching studios by name */
261
266
  export declare const StudioSearchInputSchema: z.ZodObject<{
262
267
  query: z.ZodString;
package/dist/schemas.js CHANGED
@@ -442,6 +442,13 @@ export const StaffSearchInputSchema = z.object({
442
442
  .describe("Works per person to show (default 10, max 25)"),
443
443
  page: pageParam,
444
444
  });
445
+ /** Input for listing all valid genres and tags */
446
+ export const GenreListInputSchema = z.object({
447
+ includeAdultTags: z
448
+ .boolean()
449
+ .default(false)
450
+ .describe("Include adult/NSFW tags in the list"),
451
+ });
445
452
  /** Input for searching studios by name */
446
453
  export const StudioSearchInputSchema = z.object({
447
454
  query: z
@@ -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
  }
@@ -1,4 +1,4 @@
1
- /** Info tools: staff credits, airing schedule, and character search. */
1
+ /** Info tools: staff credits, airing schedule, character search, and auth check. */
2
2
  import type { FastMCP } from "fastmcp";
3
3
  /** Register info tools on the MCP server */
4
4
  export declare function registerInfoTools(server: FastMCP): void;
@@ -1,6 +1,7 @@
1
- /** Info tools: staff credits, airing schedule, and character search. */
1
+ /** Info tools: staff credits, airing schedule, character search, and auth check. */
2
+ import { z } from "zod";
2
3
  import { anilistClient } from "../api/client.js";
3
- import { STAFF_QUERY, AIRING_SCHEDULE_QUERY, CHARACTER_SEARCH_QUERY, STAFF_SEARCH_QUERY, STUDIO_SEARCH_QUERY, } from "../api/queries.js";
4
+ import { STAFF_QUERY, AIRING_SCHEDULE_QUERY, CHARACTER_SEARCH_QUERY, STAFF_SEARCH_QUERY, STUDIO_SEARCH_QUERY, VIEWER_QUERY, } from "../api/queries.js";
4
5
  import { StaffInputSchema, ScheduleInputSchema, CharacterSearchInputSchema, StaffSearchInputSchema, StudioSearchInputSchema, } from "../schemas.js";
5
6
  import { getTitle, throwToolError, paginationFooter } from "../utils.js";
6
7
  // === Helpers ===
@@ -18,6 +19,61 @@ function formatTimeUntil(seconds) {
18
19
  // === Tool Registration ===
19
20
  /** Register info tools on the MCP server */
20
21
  export function registerInfoTools(server) {
22
+ // === Who Am I ===
23
+ server.addTool({
24
+ name: "anilist_whoami",
25
+ description: "Check which AniList account is authenticated and verify the token works. " +
26
+ "Use when the user wants to confirm their setup or debug auth issues.",
27
+ parameters: z.object({}),
28
+ annotations: {
29
+ title: "Who Am I",
30
+ readOnlyHint: true,
31
+ destructiveHint: false,
32
+ openWorldHint: true,
33
+ },
34
+ execute: async () => {
35
+ if (!process.env.ANILIST_TOKEN) {
36
+ const lines = [
37
+ "ANILIST_TOKEN is not set.",
38
+ "Set it to enable authenticated features (write operations, score format detection).",
39
+ "Get a token at: https://anilist.co/settings/developer",
40
+ ];
41
+ const envUser = process.env.ANILIST_USERNAME;
42
+ if (envUser) {
43
+ lines.push("", `ANILIST_USERNAME is set to "${envUser}" (read-only mode).`);
44
+ }
45
+ return lines.join("\n");
46
+ }
47
+ try {
48
+ const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
49
+ if (!data.Viewer) {
50
+ return throwToolError(new Error("No viewer data returned"), "checking authentication");
51
+ }
52
+ const v = data.Viewer;
53
+ const lines = [
54
+ `Authenticated as: ${v.name}`,
55
+ `AniList ID: ${v.id}`,
56
+ `Score format: ${v.mediaListOptions.scoreFormat}`,
57
+ `Profile: ${v.siteUrl}`,
58
+ ];
59
+ // Check if Anilist username matches
60
+ const envUser = process.env.ANILIST_USERNAME;
61
+ if (envUser) {
62
+ const match = envUser.toLowerCase() === v.name.toLowerCase();
63
+ lines.push("", match
64
+ ? `ANILIST_USERNAME "${envUser}" matches authenticated user.`
65
+ : `ANILIST_USERNAME "${envUser}" does not match authenticated user "${v.name}".`);
66
+ }
67
+ else {
68
+ lines.push("", "ANILIST_USERNAME is not set. Tools will require a username argument.");
69
+ }
70
+ return lines.join("\n");
71
+ }
72
+ catch (error) {
73
+ return throwToolError(error, "checking authentication");
74
+ }
75
+ },
76
+ });
21
77
  // === Staff Credits ===
22
78
  server.addTool({
23
79
  name: "anilist_staff",
@@ -205,7 +261,12 @@ export function registerInfoTools(server) {
205
261
  },
206
262
  execute: async (args) => {
207
263
  try {
208
- const data = await anilistClient.query(STAFF_SEARCH_QUERY, { search: args.query, page: args.page, perPage: args.limit, mediaPerPage: args.mediaLimit }, { cache: "search" });
264
+ const data = await anilistClient.query(STAFF_SEARCH_QUERY, {
265
+ search: args.query,
266
+ page: args.page,
267
+ perPage: args.limit,
268
+ mediaPerPage: args.mediaLimit,
269
+ }, { cache: "search" });
209
270
  const results = data.Page.staff;
210
271
  if (!results.length) {
211
272
  return `No staff found matching "${args.query}".`;
@@ -278,10 +339,7 @@ export function registerInfoTools(server) {
278
339
  const data = await anilistClient.query(STUDIO_SEARCH_QUERY, { search: args.query, perPage: args.limit }, { cache: "search" });
279
340
  const studio = data.Studio;
280
341
  const tag = studio.isAnimationStudio ? "Animation Studio" : "Studio";
281
- const lines = [
282
- `# ${studio.name} (${tag})`,
283
- "",
284
- ];
342
+ const lines = [`# ${studio.name} (${tag})`, ""];
285
343
  // Main productions first, then supporting
286
344
  const main = studio.media.edges.filter((e) => e.isMainStudio);
287
345
  const supporting = studio.media.edges.filter((e) => !e.isMainStudio);
@@ -2,7 +2,7 @@
2
2
  import { anilistClient } from "../api/client.js";
3
3
  import { USER_STATS_QUERY } from "../api/queries.js";
4
4
  import { ListInputSchema, StatsInputSchema } from "../schemas.js";
5
- import { getTitle, getDefaultUsername, throwToolError, paginationFooter } from "../utils.js";
5
+ import { getTitle, getDefaultUsername, throwToolError, paginationFooter, formatScore, detectScoreFormat, } from "../utils.js";
6
6
  // Map user-friendly sort names to AniList's internal enum values
7
7
  const SORT_MAP = {
8
8
  SCORE: ["SCORE_DESC"],
@@ -28,9 +28,16 @@ export function registerListTools(server) {
28
28
  execute: async (args) => {
29
29
  try {
30
30
  const username = getDefaultUsername(args.username);
31
+ // Fetch list and score format in parallel
31
32
  const sort = SORT_MAP[args.sort] ?? SORT_MAP.UPDATED;
32
33
  const status = args.status !== "ALL" ? args.status : undefined;
33
- const allEntries = await anilistClient.fetchList(username, args.type, status, sort);
34
+ const [allEntries, scoreFormat] = await Promise.all([
35
+ anilistClient.fetchList(username, args.type, status, sort),
36
+ detectScoreFormat(async () => {
37
+ const data = await anilistClient.query(USER_STATS_QUERY, { name: username }, { cache: "stats" });
38
+ return data.User.mediaListOptions.scoreFormat;
39
+ }),
40
+ ]);
34
41
  if (!allEntries.length) {
35
42
  if (args.status === "ALL") {
36
43
  return `${username}'s ${args.type.toLowerCase()} list is empty.`;
@@ -50,9 +57,9 @@ export function registerListTools(server) {
50
57
  (totalCount > limited.length ? `, showing ${limited.length}` : ""),
51
58
  "",
52
59
  ].join("\n");
53
- const formatted = limited.map((entry, i) => formatListEntry(entry, offset + i + 1));
60
+ const formatted = limited.map((entry, i) => formatListEntry(entry, offset + i + 1, scoreFormat));
54
61
  const footer = paginationFooter(args.page, args.limit, totalCount, hasNextPage);
55
- return header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : "");
62
+ return (header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : ""));
56
63
  }
57
64
  catch (error) {
58
65
  return throwToolError(error, "fetching list");
@@ -148,7 +155,7 @@ function formatTypeStats(stats, label) {
148
155
  return lines;
149
156
  }
150
157
  /** Format a single list entry with title, progress, score, and update date */
151
- function formatListEntry(entry, index) {
158
+ function formatListEntry(entry, index, scoreFmt) {
152
159
  const media = entry.media;
153
160
  const title = getTitle(media.title);
154
161
  const format = media.format ?? "?";
@@ -156,7 +163,7 @@ function formatListEntry(entry, index) {
156
163
  const total = media.episodes ?? media.chapters ?? "?";
157
164
  const unit = media.episodes !== null ? "ep" : "ch";
158
165
  const progress = `${entry.progress}/${total} ${unit}`;
159
- const score = entry.score > 0 ? `★ ${entry.score}/10` : "Unscored";
166
+ const score = formatScore(entry.score, scoreFmt);
160
167
  const updated = entry.updatedAt
161
168
  ? new Date(entry.updatedAt * 1000).toLocaleDateString("en-US", {
162
169
  // AniList uses Unix seconds
@@ -2,7 +2,7 @@
2
2
  import { anilistClient } from "../api/client.js";
3
3
  import { DISCOVER_MEDIA_QUERY, MEDIA_DETAILS_QUERY, RECOMMENDATIONS_QUERY, } from "../api/queries.js";
4
4
  import { TasteInputSchema, PickInputSchema, CompareInputSchema, WrappedInputSchema, ExplainInputSchema, SimilarInputSchema, } from "../schemas.js";
5
- import { getTitle, getDefaultUsername, throwToolError } from "../utils.js";
5
+ import { getTitle, getDefaultUsername, throwToolError, isNsfwEnabled, } from "../utils.js";
6
6
  import { buildTasteProfile, describeTasteProfile, } from "../engine/taste.js";
7
7
  import { matchCandidates, explainMatch } from "../engine/matcher.js";
8
8
  import { parseMood, hasMoodMatch, seasonalMoodSuggestions, } from "../engine/mood.js";
@@ -18,11 +18,13 @@ async function discoverByTaste(profile, type, completedIds) {
18
18
  const topGenres = profile.genres.slice(0, 3).map((g) => g.name);
19
19
  if (topGenres.length === 0)
20
20
  return [];
21
+ const nsfw = isNsfwEnabled();
21
22
  const data = await anilistClient.query(DISCOVER_MEDIA_QUERY, {
22
23
  type,
23
24
  genre_in: topGenres,
24
25
  perPage: 30,
25
26
  sort: ["SCORE_DESC"],
27
+ ...(nsfw ? {} : { isAdult: false }),
26
28
  }, { cache: "search" });
27
29
  return data.Page.media.filter((m) => !completedIds.has(m.id));
28
30
  }
@@ -134,6 +136,10 @@ export function registerRecommendTools(server) {
134
136
  else {
135
137
  candidates = planning.map((e) => e.media);
136
138
  }
139
+ // Filter adult content unless enabled
140
+ if (!isNsfwEnabled()) {
141
+ candidates = candidates.filter((m) => !m.isAdult);
142
+ }
137
143
  // Optionally filter by episode count
138
144
  const maxEps = args.maxEpisodes;
139
145
  if (maxEps) {
@@ -452,7 +458,7 @@ export function registerRecommendTools(server) {
452
458
  const [mediaData, { profile, entries }] = await Promise.all([
453
459
  anilistClient.query(MEDIA_DETAILS_QUERY, {
454
460
  id: args.mediaId,
455
- }),
461
+ }, { cache: "media" }),
456
462
  profileForUser(username, args.type),
457
463
  ]);
458
464
  const media = mediaData.Media;
@@ -542,20 +548,23 @@ export function registerRecommendTools(server) {
542
548
  const [detailsData, recsData] = await Promise.all([
543
549
  anilistClient.query(MEDIA_DETAILS_QUERY, {
544
550
  id: args.mediaId,
545
- }),
551
+ }, { cache: "media" }),
546
552
  anilistClient.query(RECOMMENDATIONS_QUERY, {
547
553
  id: args.mediaId,
548
554
  perPage: 25,
549
- }),
555
+ }, { cache: "media" }),
550
556
  ]);
551
557
  const source = detailsData.Media;
552
558
  const sourceTitle = getTitle(source.title);
553
559
  // Build candidate list and rec rating map
560
+ const nsfw = isNsfwEnabled();
554
561
  const candidates = [];
555
562
  const recRatings = new Map();
556
563
  for (const node of recsData.Media.recommendations.nodes) {
557
564
  if (!node.mediaRecommendation)
558
565
  continue;
566
+ if (!nsfw && node.mediaRecommendation.isAdult)
567
+ continue;
559
568
  candidates.push(node.mediaRecommendation);
560
569
  if (node.rating > 0) {
561
570
  recRatings.set(node.mediaRecommendation.id, node.rating);
@@ -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, } from "../utils.js";
5
+ import { getTitle, truncateDescription, throwToolError, formatMediaSummary, paginationFooter, isNsfwEnabled, resolveAlias, } from "../utils.js";
6
6
  // Default to popularity for broad queries
7
7
  const SEARCH_SORT = ["POPULARITY_DESC"];
8
8
  // === Tool Registration ===
@@ -23,8 +23,9 @@ export function registerSearchTools(server) {
23
23
  },
24
24
  execute: async (args) => {
25
25
  try {
26
+ const query = resolveAlias(args.query);
26
27
  const variables = {
27
- search: args.query,
28
+ search: query,
28
29
  type: args.type,
29
30
  page: args.page,
30
31
  perPage: args.limit,
@@ -53,7 +54,7 @@ export function registerSearchTools(server) {
53
54
  ].join("\n");
54
55
  const formatted = results.map((m, i) => `${offset + i + 1}. ${formatMediaSummary(m)}`);
55
56
  const footer = paginationFooter(args.page, args.limit, pageInfo.total, pageInfo.hasNextPage);
56
- return header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : "");
57
+ return (header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : ""));
57
58
  }
58
59
  catch (error) {
59
60
  return throwToolError(error, "searching");
@@ -65,7 +66,8 @@ export function registerSearchTools(server) {
65
66
  description: "Get full details about a specific anime or manga. " +
66
67
  "Use when the user asks about a specific title and wants synopsis, score, " +
67
68
  "episodes, studios, related works, and recommendations. " +
68
- "Provide either an AniList ID (faster, exact) or a title (fuzzy match).",
69
+ "Provide either an AniList ID (faster, exact) or a title (fuzzy match). " +
70
+ "Title search works with English, romaji, native names, and common abbreviations (e.g. 'aot', 'jjk').",
69
71
  parameters: DetailsInputSchema,
70
72
  annotations: {
71
73
  title: "Get Title Details",
@@ -75,12 +77,11 @@ export function registerSearchTools(server) {
75
77
  },
76
78
  execute: async (args) => {
77
79
  try {
78
- // AniList uses "search" as the GraphQL variable name for title lookups
79
80
  const variables = {};
80
81
  if (args.id)
81
82
  variables.id = args.id;
82
83
  if (args.title)
83
- variables.search = args.title;
84
+ variables.search = resolveAlias(args.title);
84
85
  const data = await anilistClient.query(MEDIA_DETAILS_QUERY, variables, { cache: "media" });
85
86
  const m = data.Media;
86
87
  const title = getTitle(m.title);
@@ -195,7 +196,7 @@ export function registerSearchTools(server) {
195
196
  ].join("\n");
196
197
  const formatted = results.map((m, i) => `${offset + i + 1}. ${formatMediaSummary(m)}`);
197
198
  const footer = paginationFooter(args.page, args.limit, pageInfo.total, pageInfo.hasNextPage);
198
- return header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : "");
199
+ return (header + formatted.join("\n\n") + (footer ? `\n\n${footer}` : ""));
199
200
  }
200
201
  catch (error) {
201
202
  return throwToolError(error, "browsing seasonal anime");
@@ -228,7 +229,10 @@ export function registerSearchTools(server) {
228
229
  const data = await anilistClient.query(RECOMMENDATIONS_QUERY, variables, { cache: "media" });
229
230
  const source = data.Media;
230
231
  const sourceTitle = getTitle(source.title);
231
- const recs = source.recommendations.nodes.filter((n) => n.mediaRecommendation && n.rating > 0);
232
+ const nsfw = isNsfwEnabled();
233
+ const recs = source.recommendations.nodes.filter((n) => n.mediaRecommendation &&
234
+ n.rating > 0 &&
235
+ (nsfw || !n.mediaRecommendation.isAdult));
232
236
  if (!recs.length) {
233
237
  return `No community recommendations found for "${sourceTitle}".`;
234
238
  }
@@ -1,8 +1,8 @@
1
1
  /** Write tools: update progress, add to list, rate, and delete entries. */
2
2
  import { anilistClient } from "../api/client.js";
3
- import { SAVE_MEDIA_LIST_ENTRY_MUTATION, DELETE_MEDIA_LIST_ENTRY_MUTATION, } from "../api/queries.js";
3
+ import { SAVE_MEDIA_LIST_ENTRY_MUTATION, DELETE_MEDIA_LIST_ENTRY_MUTATION, VIEWER_QUERY, } from "../api/queries.js";
4
4
  import { UpdateProgressInputSchema, AddToListInputSchema, RateInputSchema, DeleteFromListInputSchema, } from "../schemas.js";
5
- import { throwToolError } from "../utils.js";
5
+ import { throwToolError, formatScore, detectScoreFormat } from "../utils.js";
6
6
  // === Auth Guard ===
7
7
  /** Guard against unauthenticated write attempts */
8
8
  function requireAuth() {
@@ -23,7 +23,7 @@ export function registerWriteTools(server) {
23
23
  annotations: {
24
24
  title: "Update Progress",
25
25
  readOnlyHint: false,
26
- destructiveHint: false,
26
+ destructiveHint: true,
27
27
  idempotentHint: true,
28
28
  openWorldHint: true,
29
29
  },
@@ -60,7 +60,7 @@ export function registerWriteTools(server) {
60
60
  annotations: {
61
61
  title: "Add to List",
62
62
  readOnlyHint: false,
63
- destructiveHint: false,
63
+ destructiveHint: true,
64
64
  idempotentHint: true,
65
65
  openWorldHint: true,
66
66
  },
@@ -72,11 +72,19 @@ export function registerWriteTools(server) {
72
72
  status: args.status,
73
73
  };
74
74
  if (args.score !== undefined)
75
- variables.score = args.score;
76
- const data = await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, variables, { cache: null });
75
+ variables.scoreRaw = Math.round(args.score * 10);
76
+ const [data, scoreFmt] = await Promise.all([
77
+ anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, variables, { cache: null }),
78
+ detectScoreFormat(async () => {
79
+ const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
80
+ return data.Viewer.mediaListOptions.scoreFormat;
81
+ }),
82
+ ]);
77
83
  anilistClient.clearCache();
78
84
  const entry = data.SaveMediaListEntry;
79
- const scoreStr = entry.score > 0 ? ` | Score: ${entry.score}/10` : "";
85
+ const scoreStr = entry.score > 0
86
+ ? ` | Score: ${formatScore(entry.score, scoreFmt)}`
87
+ : "";
80
88
  return [
81
89
  `Added to list.`,
82
90
  `Status: ${entry.status}${scoreStr}`,
@@ -98,19 +106,25 @@ export function registerWriteTools(server) {
98
106
  annotations: {
99
107
  title: "Rate Title",
100
108
  readOnlyHint: false,
101
- destructiveHint: false,
109
+ destructiveHint: true,
102
110
  idempotentHint: true,
103
111
  openWorldHint: true,
104
112
  },
105
113
  execute: async (args) => {
106
114
  try {
107
115
  requireAuth();
108
- const data = await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, { mediaId: args.mediaId, score: args.score }, { cache: null });
116
+ const [data, scoreFmt] = await Promise.all([
117
+ anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, { mediaId: args.mediaId, scoreRaw: Math.round(args.score * 10) }, { cache: null }),
118
+ detectScoreFormat(async () => {
119
+ const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
120
+ return data.Viewer.mediaListOptions.scoreFormat;
121
+ }),
122
+ ]);
109
123
  anilistClient.clearCache();
110
124
  const entry = data.SaveMediaListEntry;
111
125
  const scoreDisplay = args.score === 0
112
126
  ? "Score removed."
113
- : `Score set to ${entry.score}/10.`;
127
+ : `Score set to ${formatScore(entry.score, scoreFmt)}.`;
114
128
  return [scoreDisplay, `Entry ID: ${entry.id}`].join("\n");
115
129
  }
116
130
  catch (error) {
package/dist/types.d.ts CHANGED
@@ -102,6 +102,9 @@ export interface UserStatsResponse {
102
102
  User: {
103
103
  id: number;
104
104
  name: string;
105
+ mediaListOptions: {
106
+ scoreFormat: ScoreFormat;
107
+ };
105
108
  statistics: {
106
109
  anime: MediaTypeStats;
107
110
  manga: MediaTypeStats;
@@ -350,6 +353,32 @@ export interface StaffSearchResponse {
350
353
  }>;
351
354
  };
352
355
  }
356
+ /** AniList score format options */
357
+ export type ScoreFormat = "POINT_100" | "POINT_10_DECIMAL" | "POINT_10" | "POINT_5" | "POINT_3";
358
+ /** Authenticated user info from Viewer query */
359
+ export interface ViewerResponse {
360
+ Viewer: {
361
+ id: number;
362
+ name: string;
363
+ avatar: {
364
+ medium: string | null;
365
+ };
366
+ siteUrl: string;
367
+ mediaListOptions: {
368
+ scoreFormat: ScoreFormat;
369
+ };
370
+ };
371
+ }
372
+ /** All valid genres and media tags */
373
+ export interface GenreTagCollectionResponse {
374
+ GenreCollection: string[];
375
+ MediaTagCollection: Array<{
376
+ name: string;
377
+ description: string;
378
+ category: string;
379
+ isAdult: boolean;
380
+ }>;
381
+ }
353
382
  /** Single studio with production history */
354
383
  export interface StudioSearchResponse {
355
384
  Studio: {
package/dist/utils.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  /** Formatting and resolution helpers. */
2
- import type { AniListMedia } from "./types.js";
3
- /** Best available title: English -> Romaji -> Native */
2
+ import type { AniListMedia, ScoreFormat } from "./types.js";
3
+ /** Best available title, respecting ANILIST_TITLE_LANGUAGE preference */
4
4
  export declare function getTitle(title: AniListMedia["title"]): string;
5
+ /** Whether NSFW/adult content is enabled via env var (default: false) */
6
+ export declare function isNsfwEnabled(): boolean;
7
+ /** Resolve common abbreviations to full titles */
8
+ export declare function resolveAlias(query: string): string;
5
9
  /** Truncate to max length, breaking at word boundary. Strips residual HTML. */
6
10
  export declare function truncateDescription(text: string | null, maxLength?: number): string;
7
11
  /** Resolve username from the provided arg or the configured default */
@@ -12,3 +16,7 @@ export declare function throwToolError(error: unknown, action: string): never;
12
16
  export declare function paginationFooter(page: number, limit: number, total: number, hasNextPage: boolean): string;
13
17
  /** Format a media entry as a compact multi-line summary */
14
18
  export declare function formatMediaSummary(media: AniListMedia): string;
19
+ /** Detect score format from env override or API fallback */
20
+ export declare function detectScoreFormat(fetchFormat: () => Promise<ScoreFormat>): Promise<ScoreFormat>;
21
+ /** Display a normalized 0-10 score in the user's preferred format */
22
+ export declare function formatScore(score10: number, format: ScoreFormat): string;
package/dist/utils.js CHANGED
@@ -1,9 +1,58 @@
1
1
  /** Formatting and resolution helpers. */
2
2
  import { UserError } from "fastmcp";
3
- /** Best available title: English -> Romaji -> Native */
3
+ /** Best available title, respecting ANILIST_TITLE_LANGUAGE preference */
4
4
  export function getTitle(title) {
5
+ const pref = process.env.ANILIST_TITLE_LANGUAGE?.toLowerCase();
6
+ if (pref === "romaji")
7
+ return title.romaji || title.english || title.native || "Unknown Title";
8
+ if (pref === "native")
9
+ return title.native || title.romaji || title.english || "Unknown Title";
10
+ // Default: english first
5
11
  return title.english || title.romaji || title.native || "Unknown Title";
6
12
  }
13
+ /** Whether NSFW/adult content is enabled via env var (default: false) */
14
+ export function isNsfwEnabled() {
15
+ const val = process.env.ANILIST_NSFW?.toLowerCase();
16
+ return val === "true" || val === "1";
17
+ }
18
+ // Common abbreviations to full AniList titles
19
+ const ALIAS_MAP = {
20
+ aot: "Attack on Titan",
21
+ snk: "Shingeki no Kyojin",
22
+ jjk: "Jujutsu Kaisen",
23
+ csm: "Chainsaw Man",
24
+ mha: "My Hero Academia",
25
+ bnha: "Boku no Hero Academia",
26
+ hxh: "Hunter x Hunter",
27
+ fmab: "Fullmetal Alchemist Brotherhood",
28
+ fma: "Fullmetal Alchemist",
29
+ opm: "One Punch Man",
30
+ sao: "Sword Art Online",
31
+ re0: "Re:Zero",
32
+ rezero: "Re:Zero",
33
+ konosuba: "Kono Subarashii Sekai ni Shukufuku wo!",
34
+ danmachi: "Is It Wrong to Try to Pick Up Girls in a Dungeon?",
35
+ oregairu: "My Teen Romantic Comedy SNAFU",
36
+ toradora: "Toradora!",
37
+ nge: "Neon Genesis Evangelion",
38
+ eva: "Neon Genesis Evangelion",
39
+ ttgl: "Tengen Toppa Gurren Lagann",
40
+ klk: "Kill la Kill",
41
+ jojo: "JoJo's Bizarre Adventure",
42
+ dbz: "Dragon Ball Z",
43
+ dbs: "Dragon Ball Super",
44
+ op: "One Piece",
45
+ bc: "Black Clover",
46
+ ds: "Demon Slayer",
47
+ kny: "Demon Slayer",
48
+ aob: "Blue Exorcist",
49
+ mob: "Mob Psycho 100",
50
+ yyh: "Yu Yu Hakusho",
51
+ };
52
+ /** Resolve common abbreviations to full titles */
53
+ export function resolveAlias(query) {
54
+ return ALIAS_MAP[query.toLowerCase()] ?? query;
55
+ }
7
56
  /** Truncate to max length, breaking at word boundary. Strips residual HTML. */
8
57
  export function truncateDescription(text, maxLength = 500) {
9
58
  if (!text)
@@ -75,3 +124,41 @@ export function formatMediaSummary(media) {
75
124
  lines.push(` URL: ${media.siteUrl}`);
76
125
  return lines.join("\n");
77
126
  }
127
+ /** Detect score format from env override or API fallback */
128
+ export async function detectScoreFormat(fetchFormat) {
129
+ const override = process.env.ANILIST_SCORE_FORMAT;
130
+ if (override)
131
+ return override;
132
+ try {
133
+ return await fetchFormat();
134
+ }
135
+ catch {
136
+ return "POINT_10";
137
+ }
138
+ }
139
+ /** Display a normalized 0-10 score in the user's preferred format */
140
+ export function formatScore(score10, format) {
141
+ if (score10 <= 0)
142
+ return "Unscored";
143
+ switch (format) {
144
+ case "POINT_100":
145
+ return `${Math.round(score10 * 10)}/100`;
146
+ case "POINT_10_DECIMAL":
147
+ return `${score10.toFixed(1)}/10`;
148
+ case "POINT_10":
149
+ return `${Math.round(score10)}/10`;
150
+ case "POINT_5": {
151
+ const stars = Math.round(score10 / 2);
152
+ return "★".repeat(stars) + "☆".repeat(5 - stars);
153
+ }
154
+ case "POINT_3": {
155
+ if (score10 >= 7)
156
+ return "🙂";
157
+ if (score10 >= 4)
158
+ return "😐";
159
+ return "🙁";
160
+ }
161
+ default:
162
+ return `${score10}/10`;
163
+ }
164
+ }
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ani-mcp",
4
- "version": "0.2.4",
4
+ "version": "0.3.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": {
@@ -105,6 +105,12 @@
105
105
  {
106
106
  "name": "anilist_characters"
107
107
  },
108
+ {
109
+ "name": "anilist_whoami"
110
+ },
111
+ {
112
+ "name": "anilist_genre_list"
113
+ },
108
114
  {
109
115
  "name": "anilist_update_progress"
110
116
  },
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.2.4",
4
+ "version": "0.3.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",
@@ -20,7 +20,7 @@
20
20
  "test:coverage": "vitest run --coverage",
21
21
  "test:watch": "vitest",
22
22
  "pack": "npx @anthropic-ai/mcpb pack",
23
- "release": "git tag v$(node -p \"require('./package.json').version\") && git push origin v$(node -p \"require('./package.json').version\")"
23
+ "release": "./scripts/release.sh"
24
24
  },
25
25
  "keywords": [
26
26
  "mcp",
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.2.4",
9
+ "version": "0.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ani-mcp",
14
- "version": "0.2.4",
14
+ "version": "0.3.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },