ani-mcp 0.8.3 → 0.9.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 +2 -0
- package/dist/api/mal-client.d.ts +23 -0
- package/dist/api/mal-client.js +61 -0
- package/dist/api/queries.d.ts +12 -10
- package/dist/api/queries.js +26 -1
- package/dist/engine/taste.d.ts +2 -0
- package/dist/engine/taste.js +31 -0
- package/dist/engine/undo.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/resources.js +2 -26
- package/dist/schemas.d.ts +13 -0
- package/dist/schemas.js +37 -9
- package/dist/tools/import.d.ts +4 -0
- package/dist/tools/import.js +134 -0
- package/dist/tools/info.js +73 -3
- package/dist/tools/lists.js +6 -2
- package/dist/tools/recommend.js +2 -27
- package/dist/tools/search.js +9 -1
- package/dist/tools/write.js +17 -3
- package/dist/types.d.ts +30 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +18 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -95,6 +95,7 @@ Works with any MCP-compatible client.
|
|
|
95
95
|
| `anilist_sequels` | Sequels airing this season for titles you've completed |
|
|
96
96
|
| `anilist_watch_order` | Viewing order for a franchise |
|
|
97
97
|
| `anilist_session` | Plan a viewing session within a time budget |
|
|
98
|
+
| `anilist_mal_import` | Import a MyAnimeList user's list and generate recommendations |
|
|
98
99
|
|
|
99
100
|
### Info
|
|
100
101
|
|
|
@@ -104,6 +105,7 @@ Works with any MCP-compatible client.
|
|
|
104
105
|
| `anilist_staff_search` | Search for a person by name and see all their works |
|
|
105
106
|
| `anilist_studio_search` | Search for a studio and see their productions |
|
|
106
107
|
| `anilist_schedule` | Airing schedule and next episode countdown |
|
|
108
|
+
| `anilist_airing` | Upcoming episodes for titles you're currently watching |
|
|
107
109
|
| `anilist_characters` | Search characters by name with appearances and VAs |
|
|
108
110
|
| `anilist_whoami` | Check authentication status and score format |
|
|
109
111
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Jikan (MyAnimeList) API client for read-only list import */
|
|
2
|
+
export interface JikanAnimeEntry {
|
|
3
|
+
score: number;
|
|
4
|
+
episodes_watched: number;
|
|
5
|
+
anime: {
|
|
6
|
+
mal_id: number;
|
|
7
|
+
title: string;
|
|
8
|
+
type: string;
|
|
9
|
+
episodes: number | null;
|
|
10
|
+
score: number | null;
|
|
11
|
+
genres: Array<{
|
|
12
|
+
mal_id: number;
|
|
13
|
+
name: string;
|
|
14
|
+
}>;
|
|
15
|
+
year: number | null;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Map a MAL genre name to its AniList equivalent */
|
|
19
|
+
export declare function mapMalGenre(name: string): string;
|
|
20
|
+
/** Map a MAL format string to AniList format */
|
|
21
|
+
export declare function mapMalFormat(malType: string): string;
|
|
22
|
+
/** Fetch a MAL user's completed anime list via Jikan */
|
|
23
|
+
export declare function fetchMalList(username: string, maxPages?: number): Promise<JikanAnimeEntry[]>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Jikan (MyAnimeList) API client for read-only list import */
|
|
2
|
+
import pThrottle from "p-throttle";
|
|
3
|
+
import pRetry, { AbortError } from "p-retry";
|
|
4
|
+
const JIKAN_BASE = "https://api.jikan.moe/v4";
|
|
5
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
6
|
+
// Jikan rate limit: 3 req/sec (unauthenticated)
|
|
7
|
+
const throttle = pThrottle({
|
|
8
|
+
limit: process.env.VITEST ? 10_000 : 3,
|
|
9
|
+
interval: 1_000,
|
|
10
|
+
});
|
|
11
|
+
const throttled = throttle(() => { });
|
|
12
|
+
// === Genre Mapping ===
|
|
13
|
+
// MAL and AniList share most genre names; only map exceptions
|
|
14
|
+
const MAL_TO_ANILIST_GENRE = {
|
|
15
|
+
"Sci-Fi": "Sci-Fi",
|
|
16
|
+
"Slice of Life": "Slice of Life",
|
|
17
|
+
};
|
|
18
|
+
/** Map a MAL genre name to its AniList equivalent */
|
|
19
|
+
export function mapMalGenre(name) {
|
|
20
|
+
return MAL_TO_ANILIST_GENRE[name] ?? name;
|
|
21
|
+
}
|
|
22
|
+
/** Map a MAL format string to AniList format */
|
|
23
|
+
export function mapMalFormat(malType) {
|
|
24
|
+
const map = {
|
|
25
|
+
TV: "TV",
|
|
26
|
+
Movie: "MOVIE",
|
|
27
|
+
OVA: "OVA",
|
|
28
|
+
ONA: "ONA",
|
|
29
|
+
Special: "SPECIAL",
|
|
30
|
+
Music: "MUSIC",
|
|
31
|
+
};
|
|
32
|
+
return map[malType] ?? "TV";
|
|
33
|
+
}
|
|
34
|
+
// === Client ===
|
|
35
|
+
/** Fetch a MAL user's completed anime list via Jikan */
|
|
36
|
+
export async function fetchMalList(username, maxPages = 5) {
|
|
37
|
+
const entries = [];
|
|
38
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
39
|
+
const data = await fetchPage(username, page);
|
|
40
|
+
entries.push(...data.data);
|
|
41
|
+
if (!data.pagination.has_next_page)
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
async function fetchPage(username, page) {
|
|
47
|
+
return pRetry(async () => {
|
|
48
|
+
await throttled();
|
|
49
|
+
const url = `${JIKAN_BASE}/users/${encodeURIComponent(username)}/animelist?status=completed&page=${page}`;
|
|
50
|
+
const response = await fetch(url, {
|
|
51
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
if (response.status === 404) {
|
|
55
|
+
throw new AbortError(`MAL user "${username}" not found.`);
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Jikan API error (HTTP ${response.status})`);
|
|
58
|
+
}
|
|
59
|
+
return (await response.json());
|
|
60
|
+
}, { retries: 3 });
|
|
61
|
+
}
|
package/dist/api/queries.d.ts
CHANGED
|
@@ -5,35 +5,37 @@
|
|
|
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 { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
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 { large 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 { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
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 { large 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 { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
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 { large 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 { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
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 { large 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 { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
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 { large 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 { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
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 { large 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 { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
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 { large 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) {\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: JAPANESE) {\n id\n name { full native }\n siteUrl\n }\n }\n }\n }\n }\n";
|
|
25
25
|
/** Airing schedule for currently airing anime */
|
|
26
26
|
export declare const AIRING_SCHEDULE_QUERY = "\n query AiringSchedule($id: Int, $search: String, $notYetAired: Boolean) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n status\n episodes\n nextAiringEpisode {\n episode\n airingAt\n timeUntilAiring\n }\n airingSchedule(notYetAired: $notYetAired, perPage: 10) {\n nodes {\n episode\n airingAt\n timeUntilAiring\n }\n }\n siteUrl\n }\n }\n";
|
|
27
|
+
/** Batch-fetch next airing episodes for multiple media IDs */
|
|
28
|
+
export declare const BATCH_AIRING_QUERY = "\n query BatchAiring($ids: [Int], $perPage: Int) {\n Page(perPage: $perPage) {\n media(id_in: $ids, status: RELEASING) {\n id\n title { romaji english native }\n format\n episodes\n nextAiringEpisode {\n episode\n airingAt\n timeUntilAiring\n }\n siteUrl\n }\n }\n }\n";
|
|
27
29
|
/** Search for characters by name */
|
|
28
30
|
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
31
|
/** 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 $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";
|
|
32
|
+
export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaListEntry(\n $mediaId: Int\n $status: MediaListStatus\n $scoreRaw: Int\n $progress: Int\n $progressVolumes: Int\n $notes: String\n $private: Boolean\n ) {\n SaveMediaListEntry(\n mediaId: $mediaId\n status: $status\n scoreRaw: $scoreRaw\n progress: $progress\n progressVolumes: $progressVolumes\n notes: $notes\n private: $private\n ) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n progressVolumes\n }\n }\n";
|
|
31
33
|
/** Fetch a single list entry for snapshotting before mutations */
|
|
32
|
-
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 notes\n private\n }\n }\n";
|
|
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";
|
|
33
35
|
/** Remove a list entry */
|
|
34
36
|
export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
|
|
35
37
|
/** User's anime/manga list, grouped by status. Omit $status to get all lists. */
|
|
36
|
-
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 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 { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
38
|
+
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 { large extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
37
39
|
/** Search for staff by name with their top works */
|
|
38
40
|
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";
|
|
39
41
|
/** Authenticated user info */
|
package/dist/api/queries.js
CHANGED
|
@@ -38,7 +38,8 @@ const MEDIA_FRAGMENT = `
|
|
|
38
38
|
}
|
|
39
39
|
source
|
|
40
40
|
isAdult
|
|
41
|
-
coverImage { large }
|
|
41
|
+
coverImage { large extraLarge }
|
|
42
|
+
trailer { id site thumbnail }
|
|
42
43
|
siteUrl
|
|
43
44
|
description(asHtml: false)
|
|
44
45
|
}
|
|
@@ -336,6 +337,25 @@ export const AIRING_SCHEDULE_QUERY = `
|
|
|
336
337
|
}
|
|
337
338
|
}
|
|
338
339
|
`;
|
|
340
|
+
/** Batch-fetch next airing episodes for multiple media IDs */
|
|
341
|
+
export const BATCH_AIRING_QUERY = `
|
|
342
|
+
query BatchAiring($ids: [Int], $perPage: Int) {
|
|
343
|
+
Page(perPage: $perPage) {
|
|
344
|
+
media(id_in: $ids, status: RELEASING) {
|
|
345
|
+
id
|
|
346
|
+
title { romaji english native }
|
|
347
|
+
format
|
|
348
|
+
episodes
|
|
349
|
+
nextAiringEpisode {
|
|
350
|
+
episode
|
|
351
|
+
airingAt
|
|
352
|
+
timeUntilAiring
|
|
353
|
+
}
|
|
354
|
+
siteUrl
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
`;
|
|
339
359
|
/** Search for characters by name */
|
|
340
360
|
export const CHARACTER_SEARCH_QUERY = `
|
|
341
361
|
query CharacterSearch($search: String!, $page: Int, $perPage: Int) {
|
|
@@ -375,6 +395,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
|
|
|
375
395
|
$status: MediaListStatus
|
|
376
396
|
$scoreRaw: Int
|
|
377
397
|
$progress: Int
|
|
398
|
+
$progressVolumes: Int
|
|
378
399
|
$notes: String
|
|
379
400
|
$private: Boolean
|
|
380
401
|
) {
|
|
@@ -383,6 +404,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
|
|
|
383
404
|
status: $status
|
|
384
405
|
scoreRaw: $scoreRaw
|
|
385
406
|
progress: $progress
|
|
407
|
+
progressVolumes: $progressVolumes
|
|
386
408
|
notes: $notes
|
|
387
409
|
private: $private
|
|
388
410
|
) {
|
|
@@ -391,6 +413,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
|
|
|
391
413
|
status
|
|
392
414
|
score(format: POINT_10)
|
|
393
415
|
progress
|
|
416
|
+
progressVolumes
|
|
394
417
|
}
|
|
395
418
|
}
|
|
396
419
|
`;
|
|
@@ -403,6 +426,7 @@ export const MEDIA_LIST_ENTRY_QUERY = `
|
|
|
403
426
|
status
|
|
404
427
|
score(format: POINT_10)
|
|
405
428
|
progress
|
|
429
|
+
progressVolumes
|
|
406
430
|
notes
|
|
407
431
|
private
|
|
408
432
|
}
|
|
@@ -438,6 +462,7 @@ export const USER_LIST_QUERY = `
|
|
|
438
462
|
id
|
|
439
463
|
score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting
|
|
440
464
|
progress
|
|
465
|
+
progressVolumes
|
|
441
466
|
status
|
|
442
467
|
updatedAt
|
|
443
468
|
startedAt { year month day }
|
package/dist/engine/taste.d.ts
CHANGED
|
@@ -28,3 +28,5 @@ export interface TasteProfile {
|
|
|
28
28
|
export declare function buildTasteProfile(entries: AniListMediaListEntry[]): TasteProfile;
|
|
29
29
|
/** Summarize a taste profile as natural language */
|
|
30
30
|
export declare function describeTasteProfile(profile: TasteProfile, username: string): string;
|
|
31
|
+
/** Detailed profile breakdown: genre weights, top themes, score distribution */
|
|
32
|
+
export declare function formatTasteProfileText(profile: TasteProfile): string[];
|
package/dist/engine/taste.js
CHANGED
|
@@ -177,6 +177,37 @@ function computeDecay(entry) {
|
|
|
177
177
|
const yearsSince = (now - epoch) / (365.25 * 24 * 3600);
|
|
178
178
|
return Math.exp(-DECAY_LAMBDA * Math.max(0, yearsSince));
|
|
179
179
|
}
|
|
180
|
+
/** Detailed profile breakdown: genre weights, top themes, score distribution */
|
|
181
|
+
export function formatTasteProfileText(profile) {
|
|
182
|
+
const lines = [];
|
|
183
|
+
// Detailed genre breakdown
|
|
184
|
+
if (profile.genres.length > 0) {
|
|
185
|
+
lines.push("", "Genre Weights (higher = stronger preference):");
|
|
186
|
+
for (const g of profile.genres.slice(0, 10)) {
|
|
187
|
+
lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Detailed tag breakdown
|
|
191
|
+
if (profile.tags.length > 0) {
|
|
192
|
+
lines.push("", "Top Themes:");
|
|
193
|
+
for (const t of profile.tags.slice(0, 10)) {
|
|
194
|
+
lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Score distribution bar chart
|
|
198
|
+
if (profile.scoring.totalScored > 0) {
|
|
199
|
+
lines.push("", "Score Distribution:");
|
|
200
|
+
for (let s = 10; s >= 1; s--) {
|
|
201
|
+
const count = profile.scoring.distribution[s] ?? 0;
|
|
202
|
+
if (count > 0) {
|
|
203
|
+
// Cap at 30 chars
|
|
204
|
+
const bar = "#".repeat(Math.min(count, 30));
|
|
205
|
+
lines.push(` ${s}/10: ${bar} (${count})`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return lines;
|
|
210
|
+
}
|
|
180
211
|
/** Empty profile for users with too few scored entries */
|
|
181
212
|
function emptyProfile(totalCompleted) {
|
|
182
213
|
return {
|
package/dist/engine/undo.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { registerInfoTools } from "./tools/info.js";
|
|
|
10
10
|
import { registerWriteTools } from "./tools/write.js";
|
|
11
11
|
import { registerSocialTools } from "./tools/social.js";
|
|
12
12
|
import { registerAnalyticsTools } from "./tools/analytics.js";
|
|
13
|
+
import { registerImportTools } from "./tools/import.js";
|
|
13
14
|
import { registerResources } from "./resources.js";
|
|
14
15
|
import { registerPrompts } from "./prompts.js";
|
|
15
16
|
// Both vars are optional - warn on missing so operators know what's available
|
|
@@ -21,7 +22,7 @@ if (!process.env.ANILIST_TOKEN) {
|
|
|
21
22
|
}
|
|
22
23
|
const server = new FastMCP({
|
|
23
24
|
name: "ani-mcp",
|
|
24
|
-
version: "0.
|
|
25
|
+
version: "0.9.0",
|
|
25
26
|
});
|
|
26
27
|
registerSearchTools(server);
|
|
27
28
|
registerListTools(server);
|
|
@@ -31,6 +32,7 @@ registerInfoTools(server);
|
|
|
31
32
|
registerWriteTools(server);
|
|
32
33
|
registerSocialTools(server);
|
|
33
34
|
registerAnalyticsTools(server);
|
|
35
|
+
registerImportTools(server);
|
|
34
36
|
registerResources(server);
|
|
35
37
|
registerPrompts(server);
|
|
36
38
|
// === Transport ===
|
package/dist/resources.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** MCP Resources: expose user context without tool calls */
|
|
2
2
|
import { anilistClient } from "./api/client.js";
|
|
3
3
|
import { USER_PROFILE_QUERY } from "./api/queries.js";
|
|
4
|
-
import { buildTasteProfile, describeTasteProfile, } from "./engine/taste.js";
|
|
4
|
+
import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "./engine/taste.js";
|
|
5
5
|
import { formatProfile } from "./tools/social.js";
|
|
6
6
|
import { formatListEntry } from "./tools/lists.js";
|
|
7
7
|
import { getDefaultUsername, getScoreFormat } from "./utils.js";
|
|
@@ -99,31 +99,7 @@ function formatTasteProfile(profile, username) {
|
|
|
99
99
|
`# Taste Profile: ${username}`,
|
|
100
100
|
"",
|
|
101
101
|
describeTasteProfile(profile, username),
|
|
102
|
+
...formatTasteProfileText(profile),
|
|
102
103
|
];
|
|
103
|
-
// Detailed genre breakdown
|
|
104
|
-
if (profile.genres.length > 0) {
|
|
105
|
-
lines.push("", "Genre Weights (higher = stronger preference):");
|
|
106
|
-
for (const g of profile.genres.slice(0, 10)) {
|
|
107
|
-
lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
// Detailed tag breakdown
|
|
111
|
-
if (profile.tags.length > 0) {
|
|
112
|
-
lines.push("", "Top Themes:");
|
|
113
|
-
for (const t of profile.tags.slice(0, 10)) {
|
|
114
|
-
lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
// Score distribution
|
|
118
|
-
if (profile.scoring.totalScored > 0) {
|
|
119
|
-
lines.push("", "Score Distribution:");
|
|
120
|
-
for (let s = 10; s >= 1; s--) {
|
|
121
|
-
const count = profile.scoring.distribution[s] ?? 0;
|
|
122
|
-
if (count > 0) {
|
|
123
|
-
const bar = "#".repeat(Math.min(count, 30));
|
|
124
|
-
lines.push(` ${s}/10: ${bar} (${count})`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
104
|
return lines.join("\n");
|
|
129
105
|
}
|
package/dist/schemas.d.ts
CHANGED
|
@@ -229,6 +229,18 @@ export declare const ScheduleInputSchema: z.ZodObject<{
|
|
|
229
229
|
title: z.ZodOptional<z.ZodString>;
|
|
230
230
|
}, z.core.$strip>;
|
|
231
231
|
export type ScheduleInput = z.infer<typeof ScheduleInputSchema>;
|
|
232
|
+
/** Input for airing tracker across currently watching titles */
|
|
233
|
+
export declare const AiringTrackerInputSchema: z.ZodObject<{
|
|
234
|
+
username: z.ZodOptional<z.ZodString>;
|
|
235
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
236
|
+
}, z.core.$strip>;
|
|
237
|
+
export type AiringTrackerInput = z.infer<typeof AiringTrackerInputSchema>;
|
|
238
|
+
/** Input for importing a MyAnimeList user's list for recommendations */
|
|
239
|
+
export declare const MalImportInputSchema: z.ZodObject<{
|
|
240
|
+
malUsername: z.ZodString;
|
|
241
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
242
|
+
}, z.core.$strip>;
|
|
243
|
+
export type MalImportInput = z.infer<typeof MalImportInputSchema>;
|
|
232
244
|
/** Input for character search */
|
|
233
245
|
export declare const CharacterSearchInputSchema: z.ZodObject<{
|
|
234
246
|
query: z.ZodString;
|
|
@@ -247,6 +259,7 @@ export type RecommendationsInput = z.infer<typeof RecommendationsInputSchema>;
|
|
|
247
259
|
export declare const UpdateProgressInputSchema: z.ZodObject<{
|
|
248
260
|
mediaId: z.ZodNumber;
|
|
249
261
|
progress: z.ZodNumber;
|
|
262
|
+
volumeProgress: z.ZodOptional<z.ZodNumber>;
|
|
250
263
|
status: z.ZodOptional<z.ZodEnum<{
|
|
251
264
|
CURRENT: "CURRENT";
|
|
252
265
|
COMPLETED: "COMPLETED";
|
package/dist/schemas.js
CHANGED
|
@@ -389,6 +389,34 @@ export const ScheduleInputSchema = z
|
|
|
389
389
|
.refine((data) => data.id !== undefined || data.title !== undefined, {
|
|
390
390
|
message: "Provide either an id or a title.",
|
|
391
391
|
});
|
|
392
|
+
/** Input for airing tracker across currently watching titles */
|
|
393
|
+
export const AiringTrackerInputSchema = z.object({
|
|
394
|
+
username: usernameSchema
|
|
395
|
+
.optional()
|
|
396
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
397
|
+
limit: z
|
|
398
|
+
.number()
|
|
399
|
+
.int()
|
|
400
|
+
.min(1)
|
|
401
|
+
.max(50)
|
|
402
|
+
.default(20)
|
|
403
|
+
.describe("Max titles to show (default 20, max 50)"),
|
|
404
|
+
});
|
|
405
|
+
/** Input for importing a MyAnimeList user's list for recommendations */
|
|
406
|
+
export const MalImportInputSchema = z.object({
|
|
407
|
+
malUsername: z
|
|
408
|
+
.string()
|
|
409
|
+
.min(2)
|
|
410
|
+
.max(20)
|
|
411
|
+
.describe("MyAnimeList username to import"),
|
|
412
|
+
limit: z
|
|
413
|
+
.number()
|
|
414
|
+
.int()
|
|
415
|
+
.min(1)
|
|
416
|
+
.max(15)
|
|
417
|
+
.default(5)
|
|
418
|
+
.describe("Number of recommendations to return (default 5, max 15)"),
|
|
419
|
+
});
|
|
392
420
|
/** Input for character search */
|
|
393
421
|
export const CharacterSearchInputSchema = z.object({
|
|
394
422
|
query: z
|
|
@@ -432,6 +460,12 @@ export const UpdateProgressInputSchema = z.object({
|
|
|
432
460
|
.int()
|
|
433
461
|
.min(0)
|
|
434
462
|
.describe("Episode or chapter number reached"),
|
|
463
|
+
volumeProgress: z
|
|
464
|
+
.number()
|
|
465
|
+
.int()
|
|
466
|
+
.min(0)
|
|
467
|
+
.optional()
|
|
468
|
+
.describe("Volume number reached (manga only)"),
|
|
435
469
|
status: z
|
|
436
470
|
.enum(["CURRENT", "COMPLETED", "PAUSED", "DROPPED", "REPEATING"])
|
|
437
471
|
.optional()
|
|
@@ -468,7 +502,7 @@ export const RateInputSchema = z.object({
|
|
|
468
502
|
.number()
|
|
469
503
|
.min(0)
|
|
470
504
|
.max(10)
|
|
471
|
-
.describe("Score on a 0-10 scale. Use 0 to remove a score."),
|
|
505
|
+
.describe("Score on a 0-10 scale (decimals like 7.5 are supported). Use 0 to remove a score."),
|
|
472
506
|
});
|
|
473
507
|
/** Input for removing a title from the list */
|
|
474
508
|
export const DeleteFromListInputSchema = z
|
|
@@ -498,10 +532,7 @@ export const ExplainInputSchema = z
|
|
|
498
532
|
.positive()
|
|
499
533
|
.optional()
|
|
500
534
|
.describe("AniList media ID to evaluate against your taste profile"),
|
|
501
|
-
title: z
|
|
502
|
-
.string()
|
|
503
|
-
.optional()
|
|
504
|
-
.describe("Search by title if no ID is known"),
|
|
535
|
+
title: z.string().optional().describe("Search by title if no ID is known"),
|
|
505
536
|
username: usernameSchema
|
|
506
537
|
.optional()
|
|
507
538
|
.describe("AniList username. Falls back to configured default if not provided."),
|
|
@@ -526,10 +557,7 @@ export const SimilarInputSchema = z
|
|
|
526
557
|
.positive()
|
|
527
558
|
.optional()
|
|
528
559
|
.describe("AniList media ID to find similar titles for"),
|
|
529
|
-
title: z
|
|
530
|
-
.string()
|
|
531
|
-
.optional()
|
|
532
|
-
.describe("Search by title if no ID is known"),
|
|
560
|
+
title: z.string().optional().describe("Search by title if no ID is known"),
|
|
533
561
|
limit: z
|
|
534
562
|
.number()
|
|
535
563
|
.int()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Import tools: cross-platform list import for recommendations */
|
|
2
|
+
import { fetchMalList, mapMalGenre, mapMalFormat, } from "../api/mal-client.js";
|
|
3
|
+
import { anilistClient } from "../api/client.js";
|
|
4
|
+
import { DISCOVER_MEDIA_QUERY } from "../api/queries.js";
|
|
5
|
+
import { MalImportInputSchema } from "../schemas.js";
|
|
6
|
+
import { throwToolError, formatMediaSummary } from "../utils.js";
|
|
7
|
+
import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "../engine/taste.js";
|
|
8
|
+
import { matchCandidates } from "../engine/matcher.js";
|
|
9
|
+
// === Tool Registration ===
|
|
10
|
+
/** Register import tools on the MCP server */
|
|
11
|
+
export function registerImportTools(server) {
|
|
12
|
+
server.addTool({
|
|
13
|
+
name: "anilist_mal_import",
|
|
14
|
+
description: "Import a MyAnimeList user's completed anime list and generate " +
|
|
15
|
+
"personalized recommendations based on their taste. No MAL auth needed. " +
|
|
16
|
+
"Use when the user mentions their MAL account or wants recs from MAL history. " +
|
|
17
|
+
"Returns a taste profile summary and recommended titles from AniList.",
|
|
18
|
+
parameters: MalImportInputSchema,
|
|
19
|
+
annotations: {
|
|
20
|
+
title: "MAL Import",
|
|
21
|
+
readOnlyHint: true,
|
|
22
|
+
destructiveHint: false,
|
|
23
|
+
openWorldHint: true,
|
|
24
|
+
},
|
|
25
|
+
execute: async (args) => {
|
|
26
|
+
try {
|
|
27
|
+
// Fetch MAL list via Jikan
|
|
28
|
+
const malEntries = await fetchMalList(args.malUsername);
|
|
29
|
+
if (malEntries.length === 0) {
|
|
30
|
+
return `No completed anime found for MAL user "${args.malUsername}".`;
|
|
31
|
+
}
|
|
32
|
+
// Convert to AniList-compatible format for taste engine
|
|
33
|
+
const converted = malEntriesToAniList(malEntries);
|
|
34
|
+
// Build taste profile
|
|
35
|
+
const profile = buildTasteProfile(converted);
|
|
36
|
+
const lines = [
|
|
37
|
+
`# MAL Import: ${args.malUsername}`,
|
|
38
|
+
`Imported ${malEntries.length} completed anime from MyAnimeList.`,
|
|
39
|
+
"",
|
|
40
|
+
"## Taste Profile",
|
|
41
|
+
describeTasteProfile(profile, args.malUsername),
|
|
42
|
+
...formatTasteProfileText(profile),
|
|
43
|
+
];
|
|
44
|
+
// Fetch AniList candidates using top genres
|
|
45
|
+
const topGenres = profile.genres.slice(0, 3).map((g) => g.name);
|
|
46
|
+
if (topGenres.length > 0) {
|
|
47
|
+
const candidates = await fetchDiscoverCandidates(topGenres);
|
|
48
|
+
// Filter out titles already on the MAL list
|
|
49
|
+
const malIds = new Set(malEntries.map((e) => e.anime.mal_id));
|
|
50
|
+
const filtered = candidates.filter((m) => !malIds.has(m.id));
|
|
51
|
+
// Score against taste profile
|
|
52
|
+
const ranked = matchCandidates(filtered, profile).slice(0, args.limit);
|
|
53
|
+
lines.push("", "## Recommendations", "");
|
|
54
|
+
if (ranked.length === 0) {
|
|
55
|
+
lines.push("No new recommendations found.");
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
for (let i = 0; i < ranked.length; i++) {
|
|
59
|
+
const r = ranked[i];
|
|
60
|
+
lines.push(`${i + 1}. ${formatMediaSummary(r.media)}`);
|
|
61
|
+
if (r.reasons.length > 0) {
|
|
62
|
+
lines.push(` Why: ${r.reasons.slice(0, 3).join(", ")}`);
|
|
63
|
+
}
|
|
64
|
+
lines.push("");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return throwToolError(error, "importing MAL list");
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// === Helpers ===
|
|
77
|
+
// Convert MAL entries to AniList format for the taste engine
|
|
78
|
+
function malEntriesToAniList(entries) {
|
|
79
|
+
return entries
|
|
80
|
+
.filter((e) => e.score > 0)
|
|
81
|
+
.map((e) => ({
|
|
82
|
+
id: e.anime.mal_id,
|
|
83
|
+
score: e.score,
|
|
84
|
+
progress: e.episodes_watched,
|
|
85
|
+
progressVolumes: 0,
|
|
86
|
+
status: "COMPLETED",
|
|
87
|
+
updatedAt: 0,
|
|
88
|
+
startedAt: { year: null, month: null, day: null },
|
|
89
|
+
completedAt: { year: null, month: null, day: null },
|
|
90
|
+
notes: null,
|
|
91
|
+
media: {
|
|
92
|
+
id: e.anime.mal_id,
|
|
93
|
+
type: "ANIME",
|
|
94
|
+
title: {
|
|
95
|
+
romaji: e.anime.title,
|
|
96
|
+
english: e.anime.title,
|
|
97
|
+
native: null,
|
|
98
|
+
},
|
|
99
|
+
format: mapMalFormat(e.anime.type),
|
|
100
|
+
status: "FINISHED",
|
|
101
|
+
episodes: e.anime.episodes,
|
|
102
|
+
duration: null,
|
|
103
|
+
chapters: null,
|
|
104
|
+
volumes: null,
|
|
105
|
+
meanScore: e.anime.score ? e.anime.score * 10 : null,
|
|
106
|
+
averageScore: null,
|
|
107
|
+
popularity: null,
|
|
108
|
+
genres: e.anime.genres.map((g) => mapMalGenre(g.name)),
|
|
109
|
+
tags: [],
|
|
110
|
+
season: null,
|
|
111
|
+
seasonYear: e.anime.year,
|
|
112
|
+
startDate: { year: e.anime.year, month: null, day: null },
|
|
113
|
+
endDate: { year: null, month: null, day: null },
|
|
114
|
+
studios: { nodes: [] },
|
|
115
|
+
source: null,
|
|
116
|
+
isAdult: false,
|
|
117
|
+
coverImage: { large: null, extraLarge: null },
|
|
118
|
+
trailer: null,
|
|
119
|
+
siteUrl: `https://myanimelist.net/anime/${e.anime.mal_id}`,
|
|
120
|
+
description: null,
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
// Fetch AniList discover candidates matching top taste genres
|
|
125
|
+
async function fetchDiscoverCandidates(genres) {
|
|
126
|
+
const data = await anilistClient.query(DISCOVER_MEDIA_QUERY, {
|
|
127
|
+
type: "ANIME",
|
|
128
|
+
genre_in: genres,
|
|
129
|
+
perPage: 50,
|
|
130
|
+
page: 1,
|
|
131
|
+
sort: ["POPULARITY_DESC"],
|
|
132
|
+
}, { cache: "search" });
|
|
133
|
+
return data.Page.media;
|
|
134
|
+
}
|
package/dist/tools/info.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Info tools: staff credits, airing schedule, character search, and auth check. */
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { anilistClient } from "../api/client.js";
|
|
4
|
-
import { STAFF_QUERY, AIRING_SCHEDULE_QUERY, CHARACTER_SEARCH_QUERY, STAFF_SEARCH_QUERY, STUDIO_SEARCH_QUERY, VIEWER_QUERY, } from "../api/queries.js";
|
|
5
|
-
import { StaffInputSchema, ScheduleInputSchema, CharacterSearchInputSchema, StaffSearchInputSchema, StudioSearchInputSchema, } from "../schemas.js";
|
|
6
|
-
import { getTitle, throwToolError, paginationFooter } from "../utils.js";
|
|
4
|
+
import { STAFF_QUERY, AIRING_SCHEDULE_QUERY, BATCH_AIRING_QUERY, CHARACTER_SEARCH_QUERY, STAFF_SEARCH_QUERY, STUDIO_SEARCH_QUERY, VIEWER_QUERY, } from "../api/queries.js";
|
|
5
|
+
import { StaffInputSchema, ScheduleInputSchema, AiringTrackerInputSchema, CharacterSearchInputSchema, StaffSearchInputSchema, StudioSearchInputSchema, } from "../schemas.js";
|
|
6
|
+
import { getTitle, getDefaultUsername, throwToolError, paginationFooter, } from "../utils.js";
|
|
7
7
|
// === Helpers ===
|
|
8
8
|
/** Format seconds until airing as a readable duration */
|
|
9
9
|
function formatTimeUntil(seconds) {
|
|
@@ -196,6 +196,76 @@ export function registerInfoTools(server) {
|
|
|
196
196
|
}
|
|
197
197
|
},
|
|
198
198
|
});
|
|
199
|
+
// === Airing Tracker ===
|
|
200
|
+
server.addTool({
|
|
201
|
+
name: "anilist_airing",
|
|
202
|
+
description: "Show upcoming episodes for all anime you're currently watching. " +
|
|
203
|
+
"Use when the user asks what's airing soon, what episodes are coming up, " +
|
|
204
|
+
"or wants a watchlist calendar. " +
|
|
205
|
+
"Returns titles sorted by next airing time with episode number and countdown.",
|
|
206
|
+
parameters: AiringTrackerInputSchema,
|
|
207
|
+
annotations: {
|
|
208
|
+
title: "Airing Tracker",
|
|
209
|
+
readOnlyHint: true,
|
|
210
|
+
destructiveHint: false,
|
|
211
|
+
openWorldHint: true,
|
|
212
|
+
},
|
|
213
|
+
execute: async (args) => {
|
|
214
|
+
try {
|
|
215
|
+
const username = getDefaultUsername(args.username);
|
|
216
|
+
// Fetch currently watching anime
|
|
217
|
+
const entries = await anilistClient.fetchList(username, "ANIME", "CURRENT");
|
|
218
|
+
if (!entries.length) {
|
|
219
|
+
return `${username} is not currently watching any anime.`;
|
|
220
|
+
}
|
|
221
|
+
// Extract media IDs for batch airing lookup
|
|
222
|
+
const mediaIds = entries.map((e) => e.media.id);
|
|
223
|
+
// Batch-fetch airing info (50 per page max)
|
|
224
|
+
const airingMedia = [];
|
|
225
|
+
for (let i = 0; i < mediaIds.length; i += 50) {
|
|
226
|
+
const batch = mediaIds.slice(i, i + 50);
|
|
227
|
+
const data = await anilistClient.query(BATCH_AIRING_QUERY, { ids: batch, perPage: 50 }, { cache: "schedule" });
|
|
228
|
+
airingMedia.push(...data.Page.media);
|
|
229
|
+
}
|
|
230
|
+
// Map media ID to user progress
|
|
231
|
+
const progressMap = new Map(entries.map((e) => [e.media.id, e.progress]));
|
|
232
|
+
// Sort by nearest airing time
|
|
233
|
+
const airing = airingMedia
|
|
234
|
+
.filter((m) => m.nextAiringEpisode)
|
|
235
|
+
.sort((a, b) => (a.nextAiringEpisode?.timeUntilAiring ?? Infinity) -
|
|
236
|
+
(b.nextAiringEpisode?.timeUntilAiring ?? Infinity))
|
|
237
|
+
.slice(0, args.limit);
|
|
238
|
+
const notAiringCount = entries.length - airingMedia.length;
|
|
239
|
+
const lines = [
|
|
240
|
+
`# Airing tracker for ${username}`,
|
|
241
|
+
`${entries.length} currently watching, ${airing.length} with upcoming episodes`,
|
|
242
|
+
"",
|
|
243
|
+
];
|
|
244
|
+
for (const m of airing) {
|
|
245
|
+
const next = m.nextAiringEpisode;
|
|
246
|
+
if (!next)
|
|
247
|
+
continue;
|
|
248
|
+
const title = getTitle(m.title);
|
|
249
|
+
const date = new Date(next.airingAt * 1000);
|
|
250
|
+
const dateStr = date.toLocaleDateString("en-US", {
|
|
251
|
+
weekday: "short",
|
|
252
|
+
month: "short",
|
|
253
|
+
day: "numeric",
|
|
254
|
+
});
|
|
255
|
+
const totalEp = m.episodes ? `/${m.episodes}` : "";
|
|
256
|
+
const userProgress = progressMap.get(m.id) ?? 0;
|
|
257
|
+
lines.push(`${title} (${m.format ?? "?"})`, ` Ep ${next.episode}${totalEp} - ${dateStr} (${formatTimeUntil(next.timeUntilAiring)})`, ` Your progress: ${userProgress}${totalEp} ep`, "");
|
|
258
|
+
}
|
|
259
|
+
if (notAiringCount > 0) {
|
|
260
|
+
lines.push(`${notAiringCount} title(s) not currently airing.`);
|
|
261
|
+
}
|
|
262
|
+
return lines.join("\n");
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return throwToolError(error, "tracking airing schedule");
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
});
|
|
199
269
|
// === Character Search ===
|
|
200
270
|
server.addTool({
|
|
201
271
|
name: "anilist_characters",
|
package/dist/tools/lists.js
CHANGED
|
@@ -204,10 +204,14 @@ export function formatListEntry(entry, index, scoreFmt) {
|
|
|
204
204
|
const media = entry.media;
|
|
205
205
|
const title = getTitle(media.title);
|
|
206
206
|
const format = media.format ?? "?";
|
|
207
|
-
// Progress string (e.g. "5/12 ep" or "30/? ch")
|
|
207
|
+
// Progress string (e.g. "5/12 ep" or "30/? ch, 5/20 vol")
|
|
208
208
|
const total = media.episodes ?? media.chapters ?? "?";
|
|
209
209
|
const unit = media.episodes !== null ? "ep" : "ch";
|
|
210
|
-
|
|
210
|
+
let progress = `${entry.progress}/${total} ${unit}`;
|
|
211
|
+
if (entry.progressVolumes > 0) {
|
|
212
|
+
const totalVol = media.volumes ?? "?";
|
|
213
|
+
progress += `, ${entry.progressVolumes}/${totalVol} vol`;
|
|
214
|
+
}
|
|
211
215
|
const score = formatScore(entry.score, scoreFmt);
|
|
212
216
|
const updated = entry.updatedAt
|
|
213
217
|
? new Date(entry.updatedAt * 1000).toLocaleDateString("en-US", {
|
package/dist/tools/recommend.js
CHANGED
|
@@ -4,7 +4,7 @@ import { BATCH_RELATIONS_QUERY, DISCOVER_MEDIA_QUERY, MEDIA_DETAILS_QUERY, RECOM
|
|
|
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
6
|
import { SEARCH_MEDIA_QUERY } from "../api/queries.js";
|
|
7
|
-
import { buildTasteProfile, describeTasteProfile, } from "../engine/taste.js";
|
|
7
|
+
import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "../engine/taste.js";
|
|
8
8
|
import { matchCandidates, explainMatch } from "../engine/matcher.js";
|
|
9
9
|
import { parseMood, hasMoodMatch, seasonalMoodSuggestions, } from "../engine/mood.js";
|
|
10
10
|
import { computeCompatibility, computeGenreDivergences, findCrossRecs, } from "../engine/compare.js";
|
|
@@ -80,33 +80,8 @@ export function registerRecommendTools(server) {
|
|
|
80
80
|
`# Taste Profile: ${username}`,
|
|
81
81
|
"",
|
|
82
82
|
describeTasteProfile(profile, username),
|
|
83
|
+
...formatTasteProfileText(profile),
|
|
83
84
|
];
|
|
84
|
-
// Detailed genre breakdown
|
|
85
|
-
if (profile.genres.length > 0) {
|
|
86
|
-
lines.push("", "Genre Weights (higher = stronger preference):");
|
|
87
|
-
for (const g of profile.genres.slice(0, 10)) {
|
|
88
|
-
lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
// Detailed tag breakdown
|
|
92
|
-
if (profile.tags.length > 0) {
|
|
93
|
-
lines.push("", "Top Themes:");
|
|
94
|
-
for (const t of profile.tags.slice(0, 10)) {
|
|
95
|
-
lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
// Score distribution bar chart
|
|
99
|
-
if (profile.scoring.totalScored > 0) {
|
|
100
|
-
lines.push("", "Score Distribution:");
|
|
101
|
-
for (let s = 10; s >= 1; s--) {
|
|
102
|
-
const count = profile.scoring.distribution[s] ?? 0;
|
|
103
|
-
if (count > 0) {
|
|
104
|
-
// Cap at 30 chars
|
|
105
|
-
const bar = "#".repeat(Math.min(count, 30));
|
|
106
|
-
lines.push(` ${s}/10: ${bar} (${count})`);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
85
|
return lines.join("\n");
|
|
111
86
|
}
|
|
112
87
|
catch (error) {
|
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, isNsfwEnabled, resolveAlias, resolveSeasonYear, BROWSE_SORT_MAP, } from "../utils.js";
|
|
5
|
+
import { getTitle, truncateDescription, throwToolError, formatMediaSummary, paginationFooter, isNsfwEnabled, resolveAlias, resolveSeasonYear, trailerUrl, BROWSE_SORT_MAP, } from "../utils.js";
|
|
6
6
|
// Default to popularity for broad queries
|
|
7
7
|
const SEARCH_SORT = ["POPULARITY_DESC"];
|
|
8
8
|
// === Tool Registration ===
|
|
@@ -146,6 +146,14 @@ export function registerSearchTools(server) {
|
|
|
146
146
|
lines.push(` - ${recTitle} (${r.meanScore ?? "?"}/100) - ${r.genres.slice(0, 3).join(", ")}`); // top 3 genres only
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
+
// Cover image
|
|
150
|
+
const cover = m.coverImage?.extraLarge ?? m.coverImage?.large;
|
|
151
|
+
if (cover)
|
|
152
|
+
lines.push("", `Cover: ${cover}`);
|
|
153
|
+
// Trailer
|
|
154
|
+
const tUrl = trailerUrl(m.trailer);
|
|
155
|
+
if (tUrl)
|
|
156
|
+
lines.push(`Trailer: ${tUrl}`);
|
|
149
157
|
lines.push("", `AniList: ${m.siteUrl}`);
|
|
150
158
|
return lines.join("\n");
|
|
151
159
|
}
|
package/dist/tools/write.js
CHANGED
|
@@ -68,6 +68,9 @@ export function registerWriteTools(server) {
|
|
|
68
68
|
progress: args.progress,
|
|
69
69
|
status: args.status ?? "CURRENT",
|
|
70
70
|
};
|
|
71
|
+
if (args.volumeProgress !== undefined) {
|
|
72
|
+
variables.progressVolumes = args.volumeProgress;
|
|
73
|
+
}
|
|
71
74
|
const data = await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, variables, { cache: null });
|
|
72
75
|
const viewerName = await getViewerName();
|
|
73
76
|
anilistClient.invalidateUser(viewerName);
|
|
@@ -82,10 +85,13 @@ export function registerWriteTools(server) {
|
|
|
82
85
|
timestamp: Date.now(),
|
|
83
86
|
description: `Set progress to ${args.progress} on media ${args.mediaId}`,
|
|
84
87
|
});
|
|
88
|
+
const volStr = entry.progressVolumes > 0
|
|
89
|
+
? ` | Volumes: ${entry.progressVolumes}`
|
|
90
|
+
: "";
|
|
85
91
|
return [
|
|
86
92
|
`Progress updated.`,
|
|
87
93
|
`Status: ${entry.status}`,
|
|
88
|
-
`Progress: ${entry.progress}`,
|
|
94
|
+
`Progress: ${entry.progress}${volStr}`,
|
|
89
95
|
`Entry ID: ${entry.id}`,
|
|
90
96
|
undoHint(before),
|
|
91
97
|
].join("\n");
|
|
@@ -287,6 +293,7 @@ export function registerWriteTools(server) {
|
|
|
287
293
|
status: op.before.status,
|
|
288
294
|
scoreRaw: op.before.score * 10,
|
|
289
295
|
progress: op.before.progress,
|
|
296
|
+
progressVolumes: op.before.progressVolumes,
|
|
290
297
|
}, { cache: null });
|
|
291
298
|
const viewerName = await getViewerName();
|
|
292
299
|
anilistClient.invalidateUser(viewerName);
|
|
@@ -308,6 +315,7 @@ export function registerWriteTools(server) {
|
|
|
308
315
|
status: op.before.status,
|
|
309
316
|
scoreRaw: op.before.score * 10,
|
|
310
317
|
progress: op.before.progress,
|
|
318
|
+
progressVolumes: op.before.progressVolumes,
|
|
311
319
|
}, { cache: null });
|
|
312
320
|
const viewerName = await getViewerName();
|
|
313
321
|
anilistClient.invalidateUser(viewerName);
|
|
@@ -324,6 +332,7 @@ export function registerWriteTools(server) {
|
|
|
324
332
|
status: item.before.status,
|
|
325
333
|
scoreRaw: item.before.score * 10,
|
|
326
334
|
progress: item.before.progress,
|
|
335
|
+
progressVolumes: item.before.progressVolumes,
|
|
327
336
|
}, { cache: null });
|
|
328
337
|
restored++;
|
|
329
338
|
}
|
|
@@ -378,7 +387,9 @@ export function registerWriteTools(server) {
|
|
|
378
387
|
requireAuth();
|
|
379
388
|
const variables = { [FAVOURITE_VAR_MAP[args.type]]: args.id };
|
|
380
389
|
const data = await anilistClient.query(TOGGLE_FAVOURITE_MUTATION, variables, { cache: null });
|
|
381
|
-
|
|
390
|
+
const viewerName = await getViewerName();
|
|
391
|
+
anilistClient.invalidateUser(viewerName);
|
|
392
|
+
invalidateUserProfiles(viewerName);
|
|
382
393
|
// Check if entity is now in favourites (added) or absent (removed)
|
|
383
394
|
const field = FAVOURITE_FIELD_MAP[args.type];
|
|
384
395
|
const isFavourited = data.ToggleFavourite[field].nodes.some((n) => n.id === args.id);
|
|
@@ -410,7 +421,9 @@ export function registerWriteTools(server) {
|
|
|
410
421
|
try {
|
|
411
422
|
requireAuth();
|
|
412
423
|
const data = await anilistClient.query(SAVE_TEXT_ACTIVITY_MUTATION, { text: args.text }, { cache: null });
|
|
413
|
-
|
|
424
|
+
const viewerName = await getViewerName();
|
|
425
|
+
anilistClient.invalidateUser(viewerName);
|
|
426
|
+
invalidateUserProfiles(viewerName);
|
|
414
427
|
const activity = data.SaveTextActivity;
|
|
415
428
|
const dateStr = new Date(activity.createdAt * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
416
429
|
return [
|
|
@@ -560,6 +573,7 @@ export function registerWriteTools(server) {
|
|
|
560
573
|
status: e.status,
|
|
561
574
|
score: e.score,
|
|
562
575
|
progress: e.progress,
|
|
576
|
+
progressVolumes: e.progressVolumes,
|
|
563
577
|
notes: e.notes,
|
|
564
578
|
private: false,
|
|
565
579
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -44,7 +44,13 @@ export interface AniListMedia {
|
|
|
44
44
|
isAdult: boolean;
|
|
45
45
|
coverImage: {
|
|
46
46
|
large: string | null;
|
|
47
|
+
extraLarge: string | null;
|
|
47
48
|
};
|
|
49
|
+
trailer: {
|
|
50
|
+
id: string;
|
|
51
|
+
site: string;
|
|
52
|
+
thumbnail: string;
|
|
53
|
+
} | null;
|
|
48
54
|
siteUrl: string;
|
|
49
55
|
description: string | null;
|
|
50
56
|
}
|
|
@@ -238,6 +244,27 @@ export interface AiringScheduleResponse {
|
|
|
238
244
|
siteUrl: string;
|
|
239
245
|
};
|
|
240
246
|
}
|
|
247
|
+
/** Batch airing response for currently watching tracker */
|
|
248
|
+
export interface BatchAiringResponse {
|
|
249
|
+
Page: {
|
|
250
|
+
media: Array<{
|
|
251
|
+
id: number;
|
|
252
|
+
title: {
|
|
253
|
+
romaji: string | null;
|
|
254
|
+
english: string | null;
|
|
255
|
+
native: string | null;
|
|
256
|
+
};
|
|
257
|
+
format: string | null;
|
|
258
|
+
episodes: number | null;
|
|
259
|
+
nextAiringEpisode: {
|
|
260
|
+
episode: number;
|
|
261
|
+
airingAt: number;
|
|
262
|
+
timeUntilAiring: number;
|
|
263
|
+
} | null;
|
|
264
|
+
siteUrl: string;
|
|
265
|
+
}>;
|
|
266
|
+
};
|
|
267
|
+
}
|
|
241
268
|
/** Character search results */
|
|
242
269
|
export interface CharacterSearchResponse {
|
|
243
270
|
Page: {
|
|
@@ -290,6 +317,7 @@ export interface MediaListEntryResponse {
|
|
|
290
317
|
status: string;
|
|
291
318
|
score: number;
|
|
292
319
|
progress: number;
|
|
320
|
+
progressVolumes: number;
|
|
293
321
|
notes: string | null;
|
|
294
322
|
private: boolean;
|
|
295
323
|
} | null;
|
|
@@ -302,6 +330,7 @@ export interface SaveMediaListEntryResponse {
|
|
|
302
330
|
status: string;
|
|
303
331
|
score: number;
|
|
304
332
|
progress: number;
|
|
333
|
+
progressVolumes: number;
|
|
305
334
|
};
|
|
306
335
|
}
|
|
307
336
|
/** Response from deleting a list entry */
|
|
@@ -315,6 +344,7 @@ export interface AniListMediaListEntry {
|
|
|
315
344
|
id: number;
|
|
316
345
|
score: number;
|
|
317
346
|
progress: number;
|
|
347
|
+
progressVolumes: number;
|
|
318
348
|
status: string;
|
|
319
349
|
updatedAt: number;
|
|
320
350
|
startedAt: AniListDate;
|
package/dist/utils.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export declare function throwToolError(error: unknown, action: string): never;
|
|
|
16
16
|
export declare function paginationFooter(page: number, limit: number, total: number, hasNextPage: boolean): string;
|
|
17
17
|
/** Format a media entry as a compact multi-line summary */
|
|
18
18
|
export declare function formatMediaSummary(media: AniListMedia): string;
|
|
19
|
+
/** Construct full trailer URL from site + video ID */
|
|
20
|
+
export declare function trailerUrl(trailer: AniListMedia["trailer"]): string | null;
|
|
19
21
|
/** Detect score format from env override or API fallback */
|
|
20
22
|
export declare function detectScoreFormat(fetchFormat: () => Promise<ScoreFormat>): Promise<ScoreFormat>;
|
|
21
23
|
/** Fetch score format for a user (by username) or the authenticated viewer */
|
package/dist/utils.js
CHANGED
|
@@ -129,9 +129,27 @@ export function formatMediaSummary(media) {
|
|
|
129
129
|
lines.push(` Length: ${length}`);
|
|
130
130
|
if (studios)
|
|
131
131
|
lines.push(` Studio: ${studios}`);
|
|
132
|
+
// Best available cover image
|
|
133
|
+
const cover = media.coverImage?.extraLarge ?? media.coverImage?.large;
|
|
134
|
+
if (cover)
|
|
135
|
+
lines.push(` Cover: ${cover}`);
|
|
136
|
+
// Trailer link
|
|
137
|
+
const trailer = trailerUrl(media.trailer);
|
|
138
|
+
if (trailer)
|
|
139
|
+
lines.push(` Trailer: ${trailer}`);
|
|
132
140
|
lines.push(` URL: ${media.siteUrl}`);
|
|
133
141
|
return lines.join("\n");
|
|
134
142
|
}
|
|
143
|
+
/** Construct full trailer URL from site + video ID */
|
|
144
|
+
export function trailerUrl(trailer) {
|
|
145
|
+
if (!trailer)
|
|
146
|
+
return null;
|
|
147
|
+
if (trailer.site === "youtube")
|
|
148
|
+
return `https://youtube.com/watch?v=${trailer.id}`;
|
|
149
|
+
if (trailer.site === "dailymotion")
|
|
150
|
+
return `https://dailymotion.com/video/${trailer.id}`;
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
135
153
|
/** Detect score format from env override or API fallback */
|
|
136
154
|
export async function detectScoreFormat(fetchFormat) {
|
|
137
155
|
const override = process.env.ANILIST_SCORE_FORMAT;
|
package/manifest.json
CHANGED
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.9.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",
|
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.9.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ani-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.9.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|