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 +10 -0
- package/dist/api/client.js +13 -9
- package/dist/api/queries.d.ts +6 -2
- package/dist/api/queries.js +31 -2
- package/dist/schemas.d.ts +5 -0
- package/dist/schemas.js +7 -0
- package/dist/tools/discover.js +56 -5
- package/dist/tools/info.d.ts +1 -1
- package/dist/tools/info.js +65 -7
- package/dist/tools/lists.js +13 -6
- package/dist/tools/recommend.js +13 -4
- package/dist/tools/search.js +12 -8
- package/dist/tools/write.js +24 -10
- package/dist/types.d.ts +29 -0
- package/dist/utils.d.ts +10 -2
- package/dist/utils.js +88 -1
- package/manifest.json +7 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
# ani-mcp
|
|
6
6
|
|
|
7
|
+
[](https://www.npmjs.com/package/ani-mcp)
|
|
8
|
+
[](https://github.com/gavxm/ani-mcp/actions/workflows/ci.yml)
|
|
9
|
+
[](https://opensource.org/licenses/MIT)
|
|
10
|
+
[](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
|
|
package/dist/api/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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(
|
|
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("
|
|
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) {
|
package/dist/api/queries.d.ts
CHANGED
|
@@ -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 $
|
|
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";
|
package/dist/api/queries.js
CHANGED
|
@@ -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
|
-
$
|
|
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
|
-
|
|
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
|
package/dist/tools/discover.js
CHANGED
|
@@ -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
|
}
|
package/dist/tools/info.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Info tools: staff credits, airing schedule, and
|
|
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;
|
package/dist/tools/info.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/** Info tools: staff credits, airing schedule, and
|
|
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, {
|
|
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);
|
package/dist/tools/lists.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/dist/tools/recommend.js
CHANGED
|
@@ -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);
|
package/dist/tools/search.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
}
|
package/dist/tools/write.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|
|
76
|
-
const data = await
|
|
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
|
|
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:
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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": "
|
|
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.
|
|
9
|
+
"version": "0.3.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ani-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.3.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|