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