ani-mcp 0.2.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +21 -12
- package/dist/api/queries.d.ts +17 -3
- package/dist/api/queries.js +167 -2
- package/dist/index.js +3 -1
- package/dist/schemas.d.ts +54 -0
- package/dist/schemas.js +86 -2
- 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 +64 -6
- package/dist/tools/recommend.js +13 -4
- package/dist/tools/search.js +12 -8
- package/dist/tools/social.d.ts +4 -0
- package/dist/tools/social.js +195 -0
- package/dist/tools/write.d.ts +1 -1
- package/dist/tools/write.js +105 -9
- package/dist/types.d.ts +223 -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.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Handles rate limiting (token bucket), retry with exponential backoff,
|
|
5
5
|
* and in-memory caching.
|
|
6
6
|
*/
|
|
7
|
-
import type { AniListMediaListEntry } from "../types.js";
|
|
7
|
+
import type { AniListMediaListEntry, UserListResponse } from "../types.js";
|
|
8
8
|
/** Per-category TTLs for the query cache */
|
|
9
9
|
export declare const CACHE_TTLS: {
|
|
10
10
|
readonly media: number;
|
|
@@ -31,6 +31,8 @@ declare class AniListClient {
|
|
|
31
31
|
constructor();
|
|
32
32
|
/** Execute a GraphQL query with caching and automatic retry */
|
|
33
33
|
query<T = unknown>(query: string, variables?: Record<string, unknown>, options?: QueryOptions): Promise<T>;
|
|
34
|
+
/** Fetch a user's media list groups with metadata (name, status, isCustomList) */
|
|
35
|
+
fetchListGroups(username: string, type: string, status?: string, sort?: string[]): Promise<UserListResponse["MediaListCollection"]["lists"]>;
|
|
34
36
|
/** Fetch a user's media list, flattened into a single array */
|
|
35
37
|
fetchList(username: string, type: string, status?: string, sort?: string[]): Promise<AniListMediaListEntry[]>;
|
|
36
38
|
/** Invalidate the entire query cache */
|
package/dist/api/client.js
CHANGED
|
@@ -87,17 +87,22 @@ class AniListClient {
|
|
|
87
87
|
// No cache category - skip caching entirely
|
|
88
88
|
return this.executeWithRetry(query, variables);
|
|
89
89
|
}
|
|
90
|
-
/** Fetch a user's media list
|
|
91
|
-
async
|
|
90
|
+
/** Fetch a user's media list groups with metadata (name, status, isCustomList) */
|
|
91
|
+
async fetchListGroups(username, type, status, sort) {
|
|
92
92
|
const variables = { userName: username, type };
|
|
93
93
|
if (status)
|
|
94
94
|
variables.status = status;
|
|
95
95
|
if (sort)
|
|
96
96
|
variables.sort = sort;
|
|
97
97
|
const data = await this.query(USER_LIST_QUERY, variables, { cache: "list" });
|
|
98
|
+
return data.MediaListCollection.lists;
|
|
99
|
+
}
|
|
100
|
+
/** Fetch a user's media list, flattened into a single array */
|
|
101
|
+
async fetchList(username, type, status, sort) {
|
|
102
|
+
const lists = await this.fetchListGroups(username, type, status, sort);
|
|
98
103
|
// Flatten across status groups
|
|
99
104
|
const entries = [];
|
|
100
|
-
for (const list of
|
|
105
|
+
for (const list of lists) {
|
|
101
106
|
entries.push(...list.entries);
|
|
102
107
|
}
|
|
103
108
|
return entries;
|
|
@@ -143,25 +148,29 @@ class AniListClient {
|
|
|
143
148
|
catch (error) {
|
|
144
149
|
const msg = error instanceof Error ? error.message : String(error);
|
|
145
150
|
log("network-error", msg);
|
|
146
|
-
|
|
151
|
+
const isTimeout = msg.includes("abort") || msg.includes("timeout");
|
|
152
|
+
throw new AniListApiError(isTimeout
|
|
153
|
+
? "Could not reach AniList (request timed out). Try again."
|
|
154
|
+
: `Network error connecting to AniList: ${msg}`, undefined, true);
|
|
147
155
|
}
|
|
148
156
|
// Map HTTP errors to retryable/non-retryable
|
|
149
157
|
if (!response.ok) {
|
|
150
158
|
// Read error body for context
|
|
151
159
|
const body = await response.text().catch(() => "");
|
|
152
160
|
if (response.status === 429) {
|
|
153
|
-
log("rate-limit", `429 from AniList`);
|
|
154
161
|
const retryAfter = response.headers.get("Retry-After");
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
162
|
+
const delaySec = retryAfter ? parseInt(retryAfter, 10) : 0;
|
|
163
|
+
log("rate-limit", `429 from AniList (retry-after: ${delaySec || "none"})`);
|
|
164
|
+
if (delaySec > 0) {
|
|
165
|
+
await new Promise((r) => setTimeout(r, delaySec * 1000));
|
|
160
166
|
}
|
|
161
|
-
throw new AniListApiError(
|
|
167
|
+
throw new AniListApiError(`AniList rate limit exceeded. Try again in ${delaySec > 0 ? `${delaySec} seconds` : "30-60 seconds"}.`, 429, true);
|
|
168
|
+
}
|
|
169
|
+
if (response.status === 401) {
|
|
170
|
+
throw new AbortError(new AniListApiError("Authentication failed. Check that ANILIST_TOKEN is valid and not expired.", 401, false));
|
|
162
171
|
}
|
|
163
172
|
if (response.status === 404) {
|
|
164
|
-
throw new AbortError(new AniListApiError("
|
|
173
|
+
throw new AbortError(new AniListApiError("Not found on AniList. Check that the ID or username is correct.", 404, false));
|
|
165
174
|
}
|
|
166
175
|
// Only server errors (5xx) are worth retrying
|
|
167
176
|
if (response.status >= 500) {
|
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,26 @@ export declare const AIRING_SCHEDULE_QUERY = "\n query AiringSchedule($id: Int,
|
|
|
27
27
|
/** Search for characters by name */
|
|
28
28
|
export declare const CHARACTER_SEARCH_QUERY = "\n query CharacterSearch($search: String!, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n characters(search: $search, sort: FAVOURITES_DESC) {\n id\n name { full native alternative }\n image { medium }\n favourites\n siteUrl\n media(sort: POPULARITY_DESC, perPage: 5) {\n edges {\n characterRole\n node {\n id\n title { romaji english }\n format\n type\n siteUrl\n }\n voiceActors(language: JAPANESE) {\n id\n name { full }\n siteUrl\n }\n }\n }\n }\n }\n }\n";
|
|
29
29
|
/** Create or update a list entry */
|
|
30
|
-
export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaListEntry(\n $mediaId: Int\n $status: MediaListStatus\n $
|
|
30
|
+
export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaListEntry(\n $mediaId: Int\n $status: MediaListStatus\n $scoreRaw: Int\n $progress: Int\n $notes: String\n $private: Boolean\n ) {\n SaveMediaListEntry(\n mediaId: $mediaId\n status: $status\n scoreRaw: $scoreRaw\n progress: $progress\n notes: $notes\n private: $private\n ) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n }\n }\n";
|
|
31
31
|
/** Remove a list entry */
|
|
32
32
|
export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
|
|
33
33
|
/** User's anime/manga list, grouped by status. Omit $status to get all lists. */
|
|
34
|
-
export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
34
|
+
export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n isCustomList\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
35
35
|
/** Search for staff by name with their top works */
|
|
36
36
|
export declare const STAFF_SEARCH_QUERY = "\n query StaffSearch($search: String!, $page: Int, $perPage: Int, $mediaPerPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n staff(search: $search, sort: SEARCH_MATCH) {\n id\n name { full native }\n primaryOccupations\n siteUrl\n staffMedia(sort: POPULARITY_DESC, perPage: $mediaPerPage) {\n edges {\n staffRole\n node {\n id\n title { romaji english }\n format\n type\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n }\n";
|
|
37
|
+
/** Authenticated user info */
|
|
38
|
+
export declare const VIEWER_QUERY = "\n query Viewer {\n Viewer {\n id\n name\n avatar { medium }\n siteUrl\n mediaListOptions {\n scoreFormat\n }\n }\n }\n";
|
|
39
|
+
/** All valid genres and media tags */
|
|
40
|
+
export declare const GENRE_TAG_COLLECTION_QUERY = "\n query GenreTagCollection {\n GenreCollection\n MediaTagCollection {\n name\n description\n category\n isAdult\n }\n }\n";
|
|
41
|
+
/** Toggle favourite on any entity type */
|
|
42
|
+
export declare const TOGGLE_FAVOURITE_MUTATION = "\n mutation ToggleFavourite(\n $animeId: Int\n $mangaId: Int\n $characterId: Int\n $staffId: Int\n $studioId: Int\n ) {\n ToggleFavourite(\n animeId: $animeId\n mangaId: $mangaId\n characterId: $characterId\n staffId: $staffId\n studioId: $studioId\n ) {\n anime { nodes { id } }\n manga { nodes { id } }\n characters { nodes { id } }\n staff { nodes { id } }\n studios { nodes { id } }\n }\n }\n";
|
|
43
|
+
/** Post a text activity to the authenticated user's feed */
|
|
44
|
+
export declare const SAVE_TEXT_ACTIVITY_MUTATION = "\n mutation SaveTextActivity($text: String!) {\n SaveTextActivity(text: $text) {\n id\n createdAt\n text\n user { name }\n }\n }\n";
|
|
45
|
+
/** Recent activity for a user, supports text and list activity types */
|
|
46
|
+
export declare const ACTIVITY_FEED_QUERY = "\n query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage hasNextPage }\n activities(userId: $userId, type: $type, sort: ID_DESC) {\n ... on TextActivity {\n __typename\n id\n text\n createdAt\n user { name }\n }\n ... on ListActivity {\n __typename\n id\n status\n progress\n createdAt\n user { name }\n media {\n id\n title { romaji english native }\n type\n }\n }\n }\n }\n }\n";
|
|
47
|
+
/** User profile with bio, stats summary, and top favourites */
|
|
48
|
+
export declare const USER_PROFILE_QUERY = "\n query UserProfile($name: String) {\n User(name: $name) {\n id\n name\n about\n avatar { large }\n bannerImage\n siteUrl\n createdAt\n updatedAt\n donatorTier\n statistics {\n anime {\n count\n meanScore\n episodesWatched\n minutesWatched\n }\n manga {\n count\n meanScore\n chaptersRead\n volumesRead\n }\n }\n favourites {\n anime(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n manga(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n characters(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n staff(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n studios(perPage: 5) {\n nodes { id name siteUrl }\n }\n }\n }\n }\n";
|
|
49
|
+
/** Community reviews for a media title */
|
|
50
|
+
export declare const MEDIA_REVIEWS_QUERY = "\n query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n reviews(page: $page, perPage: $perPage, sort: $sort) {\n pageInfo { total hasNextPage }\n nodes {\n id\n score\n summary\n body\n rating\n ratingAmount\n createdAt\n user { name siteUrl }\n }\n }\n }\n }\n";
|
|
37
51
|
/** Search for a studio by name with their productions */
|
|
38
52
|
export declare const STUDIO_SEARCH_QUERY = "\n query StudioSearch($search: String!, $perPage: Int) {\n Studio(search: $search, sort: SEARCH_MATCH) {\n id\n name\n isAnimationStudio\n siteUrl\n media(sort: POPULARITY_DESC, perPage: $perPage) {\n edges {\n isMainStudio\n node {\n id\n title { romaji english }\n format\n type\n status\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n";
|
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
|
|
@@ -415,6 +418,7 @@ export const USER_LIST_QUERY = `
|
|
|
415
418
|
lists {
|
|
416
419
|
name
|
|
417
420
|
status
|
|
421
|
+
isCustomList
|
|
418
422
|
entries {
|
|
419
423
|
id
|
|
420
424
|
score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting
|
|
@@ -460,6 +464,167 @@ export const STAFF_SEARCH_QUERY = `
|
|
|
460
464
|
}
|
|
461
465
|
}
|
|
462
466
|
`;
|
|
467
|
+
/** Authenticated user info */
|
|
468
|
+
export const VIEWER_QUERY = `
|
|
469
|
+
query Viewer {
|
|
470
|
+
Viewer {
|
|
471
|
+
id
|
|
472
|
+
name
|
|
473
|
+
avatar { medium }
|
|
474
|
+
siteUrl
|
|
475
|
+
mediaListOptions {
|
|
476
|
+
scoreFormat
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
`;
|
|
481
|
+
/** All valid genres and media tags */
|
|
482
|
+
export const GENRE_TAG_COLLECTION_QUERY = `
|
|
483
|
+
query GenreTagCollection {
|
|
484
|
+
GenreCollection
|
|
485
|
+
MediaTagCollection {
|
|
486
|
+
name
|
|
487
|
+
description
|
|
488
|
+
category
|
|
489
|
+
isAdult
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
`;
|
|
493
|
+
// === 0.4.0 Social & Favourites ===
|
|
494
|
+
/** Toggle favourite on any entity type */
|
|
495
|
+
export const TOGGLE_FAVOURITE_MUTATION = `
|
|
496
|
+
mutation ToggleFavourite(
|
|
497
|
+
$animeId: Int
|
|
498
|
+
$mangaId: Int
|
|
499
|
+
$characterId: Int
|
|
500
|
+
$staffId: Int
|
|
501
|
+
$studioId: Int
|
|
502
|
+
) {
|
|
503
|
+
ToggleFavourite(
|
|
504
|
+
animeId: $animeId
|
|
505
|
+
mangaId: $mangaId
|
|
506
|
+
characterId: $characterId
|
|
507
|
+
staffId: $staffId
|
|
508
|
+
studioId: $studioId
|
|
509
|
+
) {
|
|
510
|
+
anime { nodes { id } }
|
|
511
|
+
manga { nodes { id } }
|
|
512
|
+
characters { nodes { id } }
|
|
513
|
+
staff { nodes { id } }
|
|
514
|
+
studios { nodes { id } }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
`;
|
|
518
|
+
/** Post a text activity to the authenticated user's feed */
|
|
519
|
+
export const SAVE_TEXT_ACTIVITY_MUTATION = `
|
|
520
|
+
mutation SaveTextActivity($text: String!) {
|
|
521
|
+
SaveTextActivity(text: $text) {
|
|
522
|
+
id
|
|
523
|
+
createdAt
|
|
524
|
+
text
|
|
525
|
+
user { name }
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
`;
|
|
529
|
+
/** Recent activity for a user, supports text and list activity types */
|
|
530
|
+
export const ACTIVITY_FEED_QUERY = `
|
|
531
|
+
query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {
|
|
532
|
+
Page(page: $page, perPage: $perPage) {
|
|
533
|
+
pageInfo { total currentPage hasNextPage }
|
|
534
|
+
activities(userId: $userId, type: $type, sort: ID_DESC) {
|
|
535
|
+
... on TextActivity {
|
|
536
|
+
__typename
|
|
537
|
+
id
|
|
538
|
+
text
|
|
539
|
+
createdAt
|
|
540
|
+
user { name }
|
|
541
|
+
}
|
|
542
|
+
... on ListActivity {
|
|
543
|
+
__typename
|
|
544
|
+
id
|
|
545
|
+
status
|
|
546
|
+
progress
|
|
547
|
+
createdAt
|
|
548
|
+
user { name }
|
|
549
|
+
media {
|
|
550
|
+
id
|
|
551
|
+
title { romaji english native }
|
|
552
|
+
type
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
`;
|
|
559
|
+
/** User profile with bio, stats summary, and top favourites */
|
|
560
|
+
export const USER_PROFILE_QUERY = `
|
|
561
|
+
query UserProfile($name: String) {
|
|
562
|
+
User(name: $name) {
|
|
563
|
+
id
|
|
564
|
+
name
|
|
565
|
+
about
|
|
566
|
+
avatar { large }
|
|
567
|
+
bannerImage
|
|
568
|
+
siteUrl
|
|
569
|
+
createdAt
|
|
570
|
+
updatedAt
|
|
571
|
+
donatorTier
|
|
572
|
+
statistics {
|
|
573
|
+
anime {
|
|
574
|
+
count
|
|
575
|
+
meanScore
|
|
576
|
+
episodesWatched
|
|
577
|
+
minutesWatched
|
|
578
|
+
}
|
|
579
|
+
manga {
|
|
580
|
+
count
|
|
581
|
+
meanScore
|
|
582
|
+
chaptersRead
|
|
583
|
+
volumesRead
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
favourites {
|
|
587
|
+
anime(perPage: 5) {
|
|
588
|
+
nodes { id title { romaji english native } siteUrl }
|
|
589
|
+
}
|
|
590
|
+
manga(perPage: 5) {
|
|
591
|
+
nodes { id title { romaji english native } siteUrl }
|
|
592
|
+
}
|
|
593
|
+
characters(perPage: 5) {
|
|
594
|
+
nodes { id name { full } siteUrl }
|
|
595
|
+
}
|
|
596
|
+
staff(perPage: 5) {
|
|
597
|
+
nodes { id name { full } siteUrl }
|
|
598
|
+
}
|
|
599
|
+
studios(perPage: 5) {
|
|
600
|
+
nodes { id name siteUrl }
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
`;
|
|
606
|
+
/** Community reviews for a media title */
|
|
607
|
+
export const MEDIA_REVIEWS_QUERY = `
|
|
608
|
+
query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {
|
|
609
|
+
Media(id: $id, search: $search) {
|
|
610
|
+
id
|
|
611
|
+
title { romaji english native }
|
|
612
|
+
reviews(page: $page, perPage: $perPage, sort: $sort) {
|
|
613
|
+
pageInfo { total hasNextPage }
|
|
614
|
+
nodes {
|
|
615
|
+
id
|
|
616
|
+
score
|
|
617
|
+
summary
|
|
618
|
+
body
|
|
619
|
+
rating
|
|
620
|
+
ratingAmount
|
|
621
|
+
createdAt
|
|
622
|
+
user { name siteUrl }
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
`;
|
|
463
628
|
/** Search for a studio by name with their productions */
|
|
464
629
|
export const STUDIO_SEARCH_QUERY = `
|
|
465
630
|
query StudioSearch($search: String!, $perPage: Int) {
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { registerRecommendTools } from "./tools/recommend.js";
|
|
|
8
8
|
import { registerDiscoverTools } from "./tools/discover.js";
|
|
9
9
|
import { registerInfoTools } from "./tools/info.js";
|
|
10
10
|
import { registerWriteTools } from "./tools/write.js";
|
|
11
|
+
import { registerSocialTools } from "./tools/social.js";
|
|
11
12
|
// Both vars are optional - warn on missing so operators know what's available
|
|
12
13
|
if (!process.env.ANILIST_USERNAME) {
|
|
13
14
|
console.warn("ANILIST_USERNAME not set - tools will require a username argument.");
|
|
@@ -17,7 +18,7 @@ if (!process.env.ANILIST_TOKEN) {
|
|
|
17
18
|
}
|
|
18
19
|
const server = new FastMCP({
|
|
19
20
|
name: "ani-mcp",
|
|
20
|
-
version: "0.
|
|
21
|
+
version: "0.4.0",
|
|
21
22
|
});
|
|
22
23
|
registerSearchTools(server);
|
|
23
24
|
registerListTools(server);
|
|
@@ -25,6 +26,7 @@ registerRecommendTools(server);
|
|
|
25
26
|
registerDiscoverTools(server);
|
|
26
27
|
registerInfoTools(server);
|
|
27
28
|
registerWriteTools(server);
|
|
29
|
+
registerSocialTools(server);
|
|
28
30
|
// === Transport ===
|
|
29
31
|
const transport = process.env.MCP_TRANSPORT === "http" ? "httpStream" : "stdio";
|
|
30
32
|
if (transport === "httpStream") {
|
package/dist/schemas.d.ts
CHANGED
|
@@ -44,7 +44,9 @@ export declare const ListInputSchema: z.ZodObject<{
|
|
|
44
44
|
DROPPED: "DROPPED";
|
|
45
45
|
PAUSED: "PAUSED";
|
|
46
46
|
ALL: "ALL";
|
|
47
|
+
CUSTOM: "CUSTOM";
|
|
47
48
|
}>>;
|
|
49
|
+
customListName: z.ZodOptional<z.ZodString>;
|
|
48
50
|
sort: z.ZodDefault<z.ZodEnum<{
|
|
49
51
|
SCORE: "SCORE";
|
|
50
52
|
TITLE: "TITLE";
|
|
@@ -257,9 +259,61 @@ export declare const StaffSearchInputSchema: z.ZodObject<{
|
|
|
257
259
|
page: z.ZodDefault<z.ZodNumber>;
|
|
258
260
|
}, z.core.$strip>;
|
|
259
261
|
export type StaffSearchInput = z.infer<typeof StaffSearchInputSchema>;
|
|
262
|
+
/** Input for listing all valid genres and tags */
|
|
263
|
+
export declare const GenreListInputSchema: z.ZodObject<{
|
|
264
|
+
includeAdultTags: z.ZodDefault<z.ZodBoolean>;
|
|
265
|
+
}, z.core.$strip>;
|
|
266
|
+
export type GenreListInput = z.infer<typeof GenreListInputSchema>;
|
|
260
267
|
/** Input for searching studios by name */
|
|
261
268
|
export declare const StudioSearchInputSchema: z.ZodObject<{
|
|
262
269
|
query: z.ZodString;
|
|
263
270
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
264
271
|
}, z.core.$strip>;
|
|
265
272
|
export type StudioSearchInput = z.infer<typeof StudioSearchInputSchema>;
|
|
273
|
+
/** Input for toggling a favourite */
|
|
274
|
+
export declare const FavouriteInputSchema: z.ZodObject<{
|
|
275
|
+
type: z.ZodEnum<{
|
|
276
|
+
ANIME: "ANIME";
|
|
277
|
+
MANGA: "MANGA";
|
|
278
|
+
CHARACTER: "CHARACTER";
|
|
279
|
+
STAFF: "STAFF";
|
|
280
|
+
STUDIO: "STUDIO";
|
|
281
|
+
}>;
|
|
282
|
+
id: z.ZodNumber;
|
|
283
|
+
}, z.core.$strip>;
|
|
284
|
+
export type FavouriteInput = z.infer<typeof FavouriteInputSchema>;
|
|
285
|
+
/** Input for posting a text activity */
|
|
286
|
+
export declare const PostActivityInputSchema: z.ZodObject<{
|
|
287
|
+
text: z.ZodString;
|
|
288
|
+
}, z.core.$strip>;
|
|
289
|
+
export type PostActivityInput = z.infer<typeof PostActivityInputSchema>;
|
|
290
|
+
/** Input for fetching a user's activity feed */
|
|
291
|
+
export declare const FeedInputSchema: z.ZodObject<{
|
|
292
|
+
username: z.ZodOptional<z.ZodString>;
|
|
293
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
294
|
+
ALL: "ALL";
|
|
295
|
+
TEXT: "TEXT";
|
|
296
|
+
ANIME_LIST: "ANIME_LIST";
|
|
297
|
+
MANGA_LIST: "MANGA_LIST";
|
|
298
|
+
}>>;
|
|
299
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
300
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
301
|
+
}, z.core.$strip>;
|
|
302
|
+
export type FeedInput = z.infer<typeof FeedInputSchema>;
|
|
303
|
+
/** Input for viewing a user's profile */
|
|
304
|
+
export declare const ProfileInputSchema: z.ZodObject<{
|
|
305
|
+
username: z.ZodOptional<z.ZodString>;
|
|
306
|
+
}, z.core.$strip>;
|
|
307
|
+
export type ProfileInput = z.infer<typeof ProfileInputSchema>;
|
|
308
|
+
/** Input for fetching community reviews for a title */
|
|
309
|
+
export declare const ReviewsInputSchema: z.ZodObject<{
|
|
310
|
+
id: z.ZodOptional<z.ZodNumber>;
|
|
311
|
+
title: z.ZodOptional<z.ZodString>;
|
|
312
|
+
sort: z.ZodDefault<z.ZodEnum<{
|
|
313
|
+
HELPFUL: "HELPFUL";
|
|
314
|
+
NEWEST: "NEWEST";
|
|
315
|
+
}>>;
|
|
316
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
317
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
318
|
+
}, z.core.$strip>;
|
|
319
|
+
export type ReviewsInput = z.infer<typeof ReviewsInputSchema>;
|
package/dist/schemas.js
CHANGED
|
@@ -88,9 +88,13 @@ export const ListInputSchema = z.object({
|
|
|
88
88
|
.default("ANIME")
|
|
89
89
|
.describe("Get anime or manga list"),
|
|
90
90
|
status: z
|
|
91
|
-
.enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED", "ALL"])
|
|
91
|
+
.enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED", "ALL", "CUSTOM"])
|
|
92
92
|
.default("ALL")
|
|
93
|
-
.describe("Filter by list status. CURRENT = watching/reading now."),
|
|
93
|
+
.describe("Filter by list status. CURRENT = watching/reading now. CUSTOM = user-created lists."),
|
|
94
|
+
customListName: z
|
|
95
|
+
.string()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Filter to a specific custom list by name. Only used when status is CUSTOM."),
|
|
94
98
|
sort: z
|
|
95
99
|
.enum(["SCORE", "TITLE", "UPDATED", "PROGRESS"])
|
|
96
100
|
.default("UPDATED")
|
|
@@ -442,6 +446,13 @@ export const StaffSearchInputSchema = z.object({
|
|
|
442
446
|
.describe("Works per person to show (default 10, max 25)"),
|
|
443
447
|
page: pageParam,
|
|
444
448
|
});
|
|
449
|
+
/** Input for listing all valid genres and tags */
|
|
450
|
+
export const GenreListInputSchema = z.object({
|
|
451
|
+
includeAdultTags: z
|
|
452
|
+
.boolean()
|
|
453
|
+
.default(false)
|
|
454
|
+
.describe("Include adult/NSFW tags in the list"),
|
|
455
|
+
});
|
|
445
456
|
/** Input for searching studios by name */
|
|
446
457
|
export const StudioSearchInputSchema = z.object({
|
|
447
458
|
query: z
|
|
@@ -456,3 +467,76 @@ export const StudioSearchInputSchema = z.object({
|
|
|
456
467
|
.default(10)
|
|
457
468
|
.describe("Number of works to show (default 10, max 25)"),
|
|
458
469
|
});
|
|
470
|
+
// === 0.4.0 Social & Favourites ===
|
|
471
|
+
/** Input for toggling a favourite */
|
|
472
|
+
export const FavouriteInputSchema = z.object({
|
|
473
|
+
type: z
|
|
474
|
+
.enum(["ANIME", "MANGA", "CHARACTER", "STAFF", "STUDIO"])
|
|
475
|
+
.describe("Type of entity to favourite"),
|
|
476
|
+
id: z
|
|
477
|
+
.number()
|
|
478
|
+
.int()
|
|
479
|
+
.positive()
|
|
480
|
+
.describe("AniList ID of the entity to toggle favourite on"),
|
|
481
|
+
});
|
|
482
|
+
/** Input for posting a text activity */
|
|
483
|
+
export const PostActivityInputSchema = z.object({
|
|
484
|
+
text: z
|
|
485
|
+
.string()
|
|
486
|
+
.min(1)
|
|
487
|
+
.max(2000)
|
|
488
|
+
.describe("Text content of the activity post"),
|
|
489
|
+
});
|
|
490
|
+
/** Input for fetching a user's activity feed */
|
|
491
|
+
export const FeedInputSchema = z.object({
|
|
492
|
+
username: usernameSchema
|
|
493
|
+
.optional()
|
|
494
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
495
|
+
type: z
|
|
496
|
+
.enum(["TEXT", "ANIME_LIST", "MANGA_LIST", "ALL"])
|
|
497
|
+
.default("ALL")
|
|
498
|
+
.describe("Filter by activity type"),
|
|
499
|
+
limit: z
|
|
500
|
+
.number()
|
|
501
|
+
.int()
|
|
502
|
+
.min(1)
|
|
503
|
+
.max(25)
|
|
504
|
+
.default(10)
|
|
505
|
+
.describe("Number of activities to return (default 10, max 25)"),
|
|
506
|
+
page: pageParam,
|
|
507
|
+
});
|
|
508
|
+
/** Input for viewing a user's profile */
|
|
509
|
+
export const ProfileInputSchema = z.object({
|
|
510
|
+
username: usernameSchema
|
|
511
|
+
.optional()
|
|
512
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
513
|
+
});
|
|
514
|
+
/** Input for fetching community reviews for a title */
|
|
515
|
+
export const ReviewsInputSchema = z
|
|
516
|
+
.object({
|
|
517
|
+
id: z
|
|
518
|
+
.number()
|
|
519
|
+
.int()
|
|
520
|
+
.positive()
|
|
521
|
+
.optional()
|
|
522
|
+
.describe("AniList media ID"),
|
|
523
|
+
title: z
|
|
524
|
+
.string()
|
|
525
|
+
.optional()
|
|
526
|
+
.describe("Search by title if no ID is known"),
|
|
527
|
+
sort: z
|
|
528
|
+
.enum(["HELPFUL", "NEWEST"])
|
|
529
|
+
.default("HELPFUL")
|
|
530
|
+
.describe("Sort by most helpful or newest"),
|
|
531
|
+
limit: z
|
|
532
|
+
.number()
|
|
533
|
+
.int()
|
|
534
|
+
.min(1)
|
|
535
|
+
.max(10)
|
|
536
|
+
.default(5)
|
|
537
|
+
.describe("Number of reviews to return (default 5, max 10)"),
|
|
538
|
+
page: pageParam,
|
|
539
|
+
})
|
|
540
|
+
.refine((data) => data.id !== undefined || data.title !== undefined, {
|
|
541
|
+
message: "Provide either an id or a title.",
|
|
542
|
+
});
|
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
|
}
|