ani-mcp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +8 -3
- package/dist/api/queries.d.ts +11 -1
- package/dist/api/queries.js +136 -0
- package/dist/index.js +3 -1
- package/dist/schemas.d.ts +49 -0
- package/dist/schemas.js +79 -2
- package/dist/tools/lists.js +52 -1
- package/dist/tools/social.d.ts +4 -0
- package/dist/tools/social.js +195 -0
- package/dist/tools/write.d.ts +1 -1
- package/dist/tools/write.js +85 -3
- package/dist/types.d.ts +194 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/api/client.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Handles rate limiting (token bucket), retry with exponential backoff,
|
|
5
5
|
* and in-memory caching.
|
|
6
6
|
*/
|
|
7
|
-
import type { AniListMediaListEntry } from "../types.js";
|
|
7
|
+
import type { AniListMediaListEntry, UserListResponse } from "../types.js";
|
|
8
8
|
/** Per-category TTLs for the query cache */
|
|
9
9
|
export declare const CACHE_TTLS: {
|
|
10
10
|
readonly media: number;
|
|
@@ -31,6 +31,8 @@ declare class AniListClient {
|
|
|
31
31
|
constructor();
|
|
32
32
|
/** Execute a GraphQL query with caching and automatic retry */
|
|
33
33
|
query<T = unknown>(query: string, variables?: Record<string, unknown>, options?: QueryOptions): Promise<T>;
|
|
34
|
+
/** Fetch a user's media list groups with metadata (name, status, isCustomList) */
|
|
35
|
+
fetchListGroups(username: string, type: string, status?: string, sort?: string[]): Promise<UserListResponse["MediaListCollection"]["lists"]>;
|
|
34
36
|
/** Fetch a user's media list, flattened into a single array */
|
|
35
37
|
fetchList(username: string, type: string, status?: string, sort?: string[]): Promise<AniListMediaListEntry[]>;
|
|
36
38
|
/** Invalidate the entire query cache */
|
package/dist/api/client.js
CHANGED
|
@@ -87,17 +87,22 @@ class AniListClient {
|
|
|
87
87
|
// No cache category - skip caching entirely
|
|
88
88
|
return this.executeWithRetry(query, variables);
|
|
89
89
|
}
|
|
90
|
-
/** Fetch a user's media list
|
|
91
|
-
async
|
|
90
|
+
/** Fetch a user's media list groups with metadata (name, status, isCustomList) */
|
|
91
|
+
async fetchListGroups(username, type, status, sort) {
|
|
92
92
|
const variables = { userName: username, type };
|
|
93
93
|
if (status)
|
|
94
94
|
variables.status = status;
|
|
95
95
|
if (sort)
|
|
96
96
|
variables.sort = sort;
|
|
97
97
|
const data = await this.query(USER_LIST_QUERY, variables, { cache: "list" });
|
|
98
|
+
return data.MediaListCollection.lists;
|
|
99
|
+
}
|
|
100
|
+
/** Fetch a user's media list, flattened into a single array */
|
|
101
|
+
async fetchList(username, type, status, sort) {
|
|
102
|
+
const lists = await this.fetchListGroups(username, type, status, sort);
|
|
98
103
|
// Flatten across status groups
|
|
99
104
|
const entries = [];
|
|
100
|
-
for (const list of
|
|
105
|
+
for (const list of lists) {
|
|
101
106
|
entries.push(...list.entries);
|
|
102
107
|
}
|
|
103
108
|
return entries;
|
package/dist/api/queries.d.ts
CHANGED
|
@@ -31,12 +31,22 @@ export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaLis
|
|
|
31
31
|
/** Remove a list entry */
|
|
32
32
|
export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
|
|
33
33
|
/** User's anime/manga list, grouped by status. Omit $status to get all lists. */
|
|
34
|
-
export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
34
|
+
export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n isCustomList\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
35
35
|
/** Search for staff by name with their top works */
|
|
36
36
|
export declare const STAFF_SEARCH_QUERY = "\n query StaffSearch($search: String!, $page: Int, $perPage: Int, $mediaPerPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n staff(search: $search, sort: SEARCH_MATCH) {\n id\n name { full native }\n primaryOccupations\n siteUrl\n staffMedia(sort: POPULARITY_DESC, perPage: $mediaPerPage) {\n edges {\n staffRole\n node {\n id\n title { romaji english }\n format\n type\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n }\n";
|
|
37
37
|
/** Authenticated user info */
|
|
38
38
|
export declare const VIEWER_QUERY = "\n query Viewer {\n Viewer {\n id\n name\n avatar { medium }\n siteUrl\n mediaListOptions {\n scoreFormat\n }\n }\n }\n";
|
|
39
39
|
/** All valid genres and media tags */
|
|
40
40
|
export declare const GENRE_TAG_COLLECTION_QUERY = "\n query GenreTagCollection {\n GenreCollection\n MediaTagCollection {\n name\n description\n category\n isAdult\n }\n }\n";
|
|
41
|
+
/** Toggle favourite on any entity type */
|
|
42
|
+
export declare const TOGGLE_FAVOURITE_MUTATION = "\n mutation ToggleFavourite(\n $animeId: Int\n $mangaId: Int\n $characterId: Int\n $staffId: Int\n $studioId: Int\n ) {\n ToggleFavourite(\n animeId: $animeId\n mangaId: $mangaId\n characterId: $characterId\n staffId: $staffId\n studioId: $studioId\n ) {\n anime { nodes { id } }\n manga { nodes { id } }\n characters { nodes { id } }\n staff { nodes { id } }\n studios { nodes { id } }\n }\n }\n";
|
|
43
|
+
/** Post a text activity to the authenticated user's feed */
|
|
44
|
+
export declare const SAVE_TEXT_ACTIVITY_MUTATION = "\n mutation SaveTextActivity($text: String!) {\n SaveTextActivity(text: $text) {\n id\n createdAt\n text\n user { name }\n }\n }\n";
|
|
45
|
+
/** Recent activity for a user, supports text and list activity types */
|
|
46
|
+
export declare const ACTIVITY_FEED_QUERY = "\n query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage hasNextPage }\n activities(userId: $userId, type: $type, sort: ID_DESC) {\n ... on TextActivity {\n __typename\n id\n text\n createdAt\n user { name }\n }\n ... on ListActivity {\n __typename\n id\n status\n progress\n createdAt\n user { name }\n media {\n id\n title { romaji english native }\n type\n }\n }\n }\n }\n }\n";
|
|
47
|
+
/** User profile with bio, stats summary, and top favourites */
|
|
48
|
+
export declare const USER_PROFILE_QUERY = "\n query UserProfile($name: String) {\n User(name: $name) {\n id\n name\n about\n avatar { large }\n bannerImage\n siteUrl\n createdAt\n updatedAt\n donatorTier\n statistics {\n anime {\n count\n meanScore\n episodesWatched\n minutesWatched\n }\n manga {\n count\n meanScore\n chaptersRead\n volumesRead\n }\n }\n favourites {\n anime(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n manga(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n characters(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n staff(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n studios(perPage: 5) {\n nodes { id name siteUrl }\n }\n }\n }\n }\n";
|
|
49
|
+
/** Community reviews for a media title */
|
|
50
|
+
export declare const MEDIA_REVIEWS_QUERY = "\n query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n reviews(page: $page, perPage: $perPage, sort: $sort) {\n pageInfo { total hasNextPage }\n nodes {\n id\n score\n summary\n body\n rating\n ratingAmount\n createdAt\n user { name siteUrl }\n }\n }\n }\n }\n";
|
|
41
51
|
/** Search for a studio by name with their productions */
|
|
42
52
|
export declare const STUDIO_SEARCH_QUERY = "\n query StudioSearch($search: String!, $perPage: Int) {\n Studio(search: $search, sort: SEARCH_MATCH) {\n id\n name\n isAnimationStudio\n siteUrl\n media(sort: POPULARITY_DESC, perPage: $perPage) {\n edges {\n isMainStudio\n node {\n id\n title { romaji english }\n format\n type\n status\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n";
|
package/dist/api/queries.js
CHANGED
|
@@ -418,6 +418,7 @@ export const USER_LIST_QUERY = `
|
|
|
418
418
|
lists {
|
|
419
419
|
name
|
|
420
420
|
status
|
|
421
|
+
isCustomList
|
|
421
422
|
entries {
|
|
422
423
|
id
|
|
423
424
|
score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting
|
|
@@ -489,6 +490,141 @@ export const GENRE_TAG_COLLECTION_QUERY = `
|
|
|
489
490
|
}
|
|
490
491
|
}
|
|
491
492
|
`;
|
|
493
|
+
// === 0.4.0 Social & Favourites ===
|
|
494
|
+
/** Toggle favourite on any entity type */
|
|
495
|
+
export const TOGGLE_FAVOURITE_MUTATION = `
|
|
496
|
+
mutation ToggleFavourite(
|
|
497
|
+
$animeId: Int
|
|
498
|
+
$mangaId: Int
|
|
499
|
+
$characterId: Int
|
|
500
|
+
$staffId: Int
|
|
501
|
+
$studioId: Int
|
|
502
|
+
) {
|
|
503
|
+
ToggleFavourite(
|
|
504
|
+
animeId: $animeId
|
|
505
|
+
mangaId: $mangaId
|
|
506
|
+
characterId: $characterId
|
|
507
|
+
staffId: $staffId
|
|
508
|
+
studioId: $studioId
|
|
509
|
+
) {
|
|
510
|
+
anime { nodes { id } }
|
|
511
|
+
manga { nodes { id } }
|
|
512
|
+
characters { nodes { id } }
|
|
513
|
+
staff { nodes { id } }
|
|
514
|
+
studios { nodes { id } }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
`;
|
|
518
|
+
/** Post a text activity to the authenticated user's feed */
|
|
519
|
+
export const SAVE_TEXT_ACTIVITY_MUTATION = `
|
|
520
|
+
mutation SaveTextActivity($text: String!) {
|
|
521
|
+
SaveTextActivity(text: $text) {
|
|
522
|
+
id
|
|
523
|
+
createdAt
|
|
524
|
+
text
|
|
525
|
+
user { name }
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
`;
|
|
529
|
+
/** Recent activity for a user, supports text and list activity types */
|
|
530
|
+
export const ACTIVITY_FEED_QUERY = `
|
|
531
|
+
query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {
|
|
532
|
+
Page(page: $page, perPage: $perPage) {
|
|
533
|
+
pageInfo { total currentPage hasNextPage }
|
|
534
|
+
activities(userId: $userId, type: $type, sort: ID_DESC) {
|
|
535
|
+
... on TextActivity {
|
|
536
|
+
__typename
|
|
537
|
+
id
|
|
538
|
+
text
|
|
539
|
+
createdAt
|
|
540
|
+
user { name }
|
|
541
|
+
}
|
|
542
|
+
... on ListActivity {
|
|
543
|
+
__typename
|
|
544
|
+
id
|
|
545
|
+
status
|
|
546
|
+
progress
|
|
547
|
+
createdAt
|
|
548
|
+
user { name }
|
|
549
|
+
media {
|
|
550
|
+
id
|
|
551
|
+
title { romaji english native }
|
|
552
|
+
type
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
`;
|
|
559
|
+
/** User profile with bio, stats summary, and top favourites */
|
|
560
|
+
export const USER_PROFILE_QUERY = `
|
|
561
|
+
query UserProfile($name: String) {
|
|
562
|
+
User(name: $name) {
|
|
563
|
+
id
|
|
564
|
+
name
|
|
565
|
+
about
|
|
566
|
+
avatar { large }
|
|
567
|
+
bannerImage
|
|
568
|
+
siteUrl
|
|
569
|
+
createdAt
|
|
570
|
+
updatedAt
|
|
571
|
+
donatorTier
|
|
572
|
+
statistics {
|
|
573
|
+
anime {
|
|
574
|
+
count
|
|
575
|
+
meanScore
|
|
576
|
+
episodesWatched
|
|
577
|
+
minutesWatched
|
|
578
|
+
}
|
|
579
|
+
manga {
|
|
580
|
+
count
|
|
581
|
+
meanScore
|
|
582
|
+
chaptersRead
|
|
583
|
+
volumesRead
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
favourites {
|
|
587
|
+
anime(perPage: 5) {
|
|
588
|
+
nodes { id title { romaji english native } siteUrl }
|
|
589
|
+
}
|
|
590
|
+
manga(perPage: 5) {
|
|
591
|
+
nodes { id title { romaji english native } siteUrl }
|
|
592
|
+
}
|
|
593
|
+
characters(perPage: 5) {
|
|
594
|
+
nodes { id name { full } siteUrl }
|
|
595
|
+
}
|
|
596
|
+
staff(perPage: 5) {
|
|
597
|
+
nodes { id name { full } siteUrl }
|
|
598
|
+
}
|
|
599
|
+
studios(perPage: 5) {
|
|
600
|
+
nodes { id name siteUrl }
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
`;
|
|
606
|
+
/** Community reviews for a media title */
|
|
607
|
+
export const MEDIA_REVIEWS_QUERY = `
|
|
608
|
+
query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {
|
|
609
|
+
Media(id: $id, search: $search) {
|
|
610
|
+
id
|
|
611
|
+
title { romaji english native }
|
|
612
|
+
reviews(page: $page, perPage: $perPage, sort: $sort) {
|
|
613
|
+
pageInfo { total hasNextPage }
|
|
614
|
+
nodes {
|
|
615
|
+
id
|
|
616
|
+
score
|
|
617
|
+
summary
|
|
618
|
+
body
|
|
619
|
+
rating
|
|
620
|
+
ratingAmount
|
|
621
|
+
createdAt
|
|
622
|
+
user { name siteUrl }
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
`;
|
|
492
628
|
/** Search for a studio by name with their productions */
|
|
493
629
|
export const STUDIO_SEARCH_QUERY = `
|
|
494
630
|
query StudioSearch($search: String!, $perPage: Int) {
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { registerRecommendTools } from "./tools/recommend.js";
|
|
|
8
8
|
import { registerDiscoverTools } from "./tools/discover.js";
|
|
9
9
|
import { registerInfoTools } from "./tools/info.js";
|
|
10
10
|
import { registerWriteTools } from "./tools/write.js";
|
|
11
|
+
import { registerSocialTools } from "./tools/social.js";
|
|
11
12
|
// Both vars are optional - warn on missing so operators know what's available
|
|
12
13
|
if (!process.env.ANILIST_USERNAME) {
|
|
13
14
|
console.warn("ANILIST_USERNAME not set - tools will require a username argument.");
|
|
@@ -17,7 +18,7 @@ if (!process.env.ANILIST_TOKEN) {
|
|
|
17
18
|
}
|
|
18
19
|
const server = new FastMCP({
|
|
19
20
|
name: "ani-mcp",
|
|
20
|
-
version: "0.
|
|
21
|
+
version: "0.4.0",
|
|
21
22
|
});
|
|
22
23
|
registerSearchTools(server);
|
|
23
24
|
registerListTools(server);
|
|
@@ -25,6 +26,7 @@ registerRecommendTools(server);
|
|
|
25
26
|
registerDiscoverTools(server);
|
|
26
27
|
registerInfoTools(server);
|
|
27
28
|
registerWriteTools(server);
|
|
29
|
+
registerSocialTools(server);
|
|
28
30
|
// === Transport ===
|
|
29
31
|
const transport = process.env.MCP_TRANSPORT === "http" ? "httpStream" : "stdio";
|
|
30
32
|
if (transport === "httpStream") {
|
package/dist/schemas.d.ts
CHANGED
|
@@ -44,7 +44,9 @@ export declare const ListInputSchema: z.ZodObject<{
|
|
|
44
44
|
DROPPED: "DROPPED";
|
|
45
45
|
PAUSED: "PAUSED";
|
|
46
46
|
ALL: "ALL";
|
|
47
|
+
CUSTOM: "CUSTOM";
|
|
47
48
|
}>>;
|
|
49
|
+
customListName: z.ZodOptional<z.ZodString>;
|
|
48
50
|
sort: z.ZodDefault<z.ZodEnum<{
|
|
49
51
|
SCORE: "SCORE";
|
|
50
52
|
TITLE: "TITLE";
|
|
@@ -268,3 +270,50 @@ export declare const StudioSearchInputSchema: z.ZodObject<{
|
|
|
268
270
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
269
271
|
}, z.core.$strip>;
|
|
270
272
|
export type StudioSearchInput = z.infer<typeof StudioSearchInputSchema>;
|
|
273
|
+
/** Input for toggling a favourite */
|
|
274
|
+
export declare const FavouriteInputSchema: z.ZodObject<{
|
|
275
|
+
type: z.ZodEnum<{
|
|
276
|
+
ANIME: "ANIME";
|
|
277
|
+
MANGA: "MANGA";
|
|
278
|
+
CHARACTER: "CHARACTER";
|
|
279
|
+
STAFF: "STAFF";
|
|
280
|
+
STUDIO: "STUDIO";
|
|
281
|
+
}>;
|
|
282
|
+
id: z.ZodNumber;
|
|
283
|
+
}, z.core.$strip>;
|
|
284
|
+
export type FavouriteInput = z.infer<typeof FavouriteInputSchema>;
|
|
285
|
+
/** Input for posting a text activity */
|
|
286
|
+
export declare const PostActivityInputSchema: z.ZodObject<{
|
|
287
|
+
text: z.ZodString;
|
|
288
|
+
}, z.core.$strip>;
|
|
289
|
+
export type PostActivityInput = z.infer<typeof PostActivityInputSchema>;
|
|
290
|
+
/** Input for fetching a user's activity feed */
|
|
291
|
+
export declare const FeedInputSchema: z.ZodObject<{
|
|
292
|
+
username: z.ZodOptional<z.ZodString>;
|
|
293
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
294
|
+
ALL: "ALL";
|
|
295
|
+
TEXT: "TEXT";
|
|
296
|
+
ANIME_LIST: "ANIME_LIST";
|
|
297
|
+
MANGA_LIST: "MANGA_LIST";
|
|
298
|
+
}>>;
|
|
299
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
300
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
301
|
+
}, z.core.$strip>;
|
|
302
|
+
export type FeedInput = z.infer<typeof FeedInputSchema>;
|
|
303
|
+
/** Input for viewing a user's profile */
|
|
304
|
+
export declare const ProfileInputSchema: z.ZodObject<{
|
|
305
|
+
username: z.ZodOptional<z.ZodString>;
|
|
306
|
+
}, z.core.$strip>;
|
|
307
|
+
export type ProfileInput = z.infer<typeof ProfileInputSchema>;
|
|
308
|
+
/** Input for fetching community reviews for a title */
|
|
309
|
+
export declare const ReviewsInputSchema: z.ZodObject<{
|
|
310
|
+
id: z.ZodOptional<z.ZodNumber>;
|
|
311
|
+
title: z.ZodOptional<z.ZodString>;
|
|
312
|
+
sort: z.ZodDefault<z.ZodEnum<{
|
|
313
|
+
HELPFUL: "HELPFUL";
|
|
314
|
+
NEWEST: "NEWEST";
|
|
315
|
+
}>>;
|
|
316
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
317
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
318
|
+
}, z.core.$strip>;
|
|
319
|
+
export type ReviewsInput = z.infer<typeof ReviewsInputSchema>;
|
package/dist/schemas.js
CHANGED
|
@@ -88,9 +88,13 @@ export const ListInputSchema = z.object({
|
|
|
88
88
|
.default("ANIME")
|
|
89
89
|
.describe("Get anime or manga list"),
|
|
90
90
|
status: z
|
|
91
|
-
.enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED", "ALL"])
|
|
91
|
+
.enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED", "ALL", "CUSTOM"])
|
|
92
92
|
.default("ALL")
|
|
93
|
-
.describe("Filter by list status. CURRENT = watching/reading now."),
|
|
93
|
+
.describe("Filter by list status. CURRENT = watching/reading now. CUSTOM = user-created lists."),
|
|
94
|
+
customListName: z
|
|
95
|
+
.string()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Filter to a specific custom list by name. Only used when status is CUSTOM."),
|
|
94
98
|
sort: z
|
|
95
99
|
.enum(["SCORE", "TITLE", "UPDATED", "PROGRESS"])
|
|
96
100
|
.default("UPDATED")
|
|
@@ -463,3 +467,76 @@ export const StudioSearchInputSchema = z.object({
|
|
|
463
467
|
.default(10)
|
|
464
468
|
.describe("Number of works to show (default 10, max 25)"),
|
|
465
469
|
});
|
|
470
|
+
// === 0.4.0 Social & Favourites ===
|
|
471
|
+
/** Input for toggling a favourite */
|
|
472
|
+
export const FavouriteInputSchema = z.object({
|
|
473
|
+
type: z
|
|
474
|
+
.enum(["ANIME", "MANGA", "CHARACTER", "STAFF", "STUDIO"])
|
|
475
|
+
.describe("Type of entity to favourite"),
|
|
476
|
+
id: z
|
|
477
|
+
.number()
|
|
478
|
+
.int()
|
|
479
|
+
.positive()
|
|
480
|
+
.describe("AniList ID of the entity to toggle favourite on"),
|
|
481
|
+
});
|
|
482
|
+
/** Input for posting a text activity */
|
|
483
|
+
export const PostActivityInputSchema = z.object({
|
|
484
|
+
text: z
|
|
485
|
+
.string()
|
|
486
|
+
.min(1)
|
|
487
|
+
.max(2000)
|
|
488
|
+
.describe("Text content of the activity post"),
|
|
489
|
+
});
|
|
490
|
+
/** Input for fetching a user's activity feed */
|
|
491
|
+
export const FeedInputSchema = z.object({
|
|
492
|
+
username: usernameSchema
|
|
493
|
+
.optional()
|
|
494
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
495
|
+
type: z
|
|
496
|
+
.enum(["TEXT", "ANIME_LIST", "MANGA_LIST", "ALL"])
|
|
497
|
+
.default("ALL")
|
|
498
|
+
.describe("Filter by activity type"),
|
|
499
|
+
limit: z
|
|
500
|
+
.number()
|
|
501
|
+
.int()
|
|
502
|
+
.min(1)
|
|
503
|
+
.max(25)
|
|
504
|
+
.default(10)
|
|
505
|
+
.describe("Number of activities to return (default 10, max 25)"),
|
|
506
|
+
page: pageParam,
|
|
507
|
+
});
|
|
508
|
+
/** Input for viewing a user's profile */
|
|
509
|
+
export const ProfileInputSchema = z.object({
|
|
510
|
+
username: usernameSchema
|
|
511
|
+
.optional()
|
|
512
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
513
|
+
});
|
|
514
|
+
/** Input for fetching community reviews for a title */
|
|
515
|
+
export const ReviewsInputSchema = z
|
|
516
|
+
.object({
|
|
517
|
+
id: z
|
|
518
|
+
.number()
|
|
519
|
+
.int()
|
|
520
|
+
.positive()
|
|
521
|
+
.optional()
|
|
522
|
+
.describe("AniList media ID"),
|
|
523
|
+
title: z
|
|
524
|
+
.string()
|
|
525
|
+
.optional()
|
|
526
|
+
.describe("Search by title if no ID is known"),
|
|
527
|
+
sort: z
|
|
528
|
+
.enum(["HELPFUL", "NEWEST"])
|
|
529
|
+
.default("HELPFUL")
|
|
530
|
+
.describe("Sort by most helpful or newest"),
|
|
531
|
+
limit: z
|
|
532
|
+
.number()
|
|
533
|
+
.int()
|
|
534
|
+
.min(1)
|
|
535
|
+
.max(10)
|
|
536
|
+
.default(5)
|
|
537
|
+
.describe("Number of reviews to return (default 5, max 10)"),
|
|
538
|
+
page: pageParam,
|
|
539
|
+
})
|
|
540
|
+
.refine((data) => data.id !== undefined || data.title !== undefined, {
|
|
541
|
+
message: "Provide either an id or a title.",
|
|
542
|
+
});
|
package/dist/tools/lists.js
CHANGED
|
@@ -28,8 +28,12 @@ export function registerListTools(server) {
|
|
|
28
28
|
execute: async (args) => {
|
|
29
29
|
try {
|
|
30
30
|
const username = getDefaultUsername(args.username);
|
|
31
|
-
// Fetch list and score format in parallel
|
|
32
31
|
const sort = SORT_MAP[args.sort] ?? SORT_MAP.UPDATED;
|
|
32
|
+
// Custom list path: fetch all groups and filter to custom lists
|
|
33
|
+
if (args.status === "CUSTOM") {
|
|
34
|
+
return await handleCustomLists(username, args, sort);
|
|
35
|
+
}
|
|
36
|
+
// Standard path: fetch list and score format in parallel
|
|
33
37
|
const status = args.status !== "ALL" ? args.status : undefined;
|
|
34
38
|
const [allEntries, scoreFormat] = await Promise.all([
|
|
35
39
|
anilistClient.fetchList(username, args.type, status, sort),
|
|
@@ -107,6 +111,53 @@ export function registerListTools(server) {
|
|
|
107
111
|
},
|
|
108
112
|
});
|
|
109
113
|
}
|
|
114
|
+
/** Fetch and format custom lists for a user */
|
|
115
|
+
async function handleCustomLists(username, args, sort) {
|
|
116
|
+
const groups = await anilistClient.fetchListGroups(username, args.type, undefined, sort);
|
|
117
|
+
let customLists = groups.filter((g) => g.isCustomList);
|
|
118
|
+
if (!customLists.length) {
|
|
119
|
+
return `${username} has no custom ${args.type.toLowerCase()} lists.`;
|
|
120
|
+
}
|
|
121
|
+
// Filter to a specific named list
|
|
122
|
+
if (args.customListName) {
|
|
123
|
+
const target = args.customListName.toLowerCase();
|
|
124
|
+
const match = customLists.filter((g) => g.name.toLowerCase() === target);
|
|
125
|
+
if (!match.length) {
|
|
126
|
+
const names = customLists.map((g) => g.name).join(", ");
|
|
127
|
+
return `Custom list "${args.customListName}" not found. Available: ${names}`;
|
|
128
|
+
}
|
|
129
|
+
customLists = match;
|
|
130
|
+
}
|
|
131
|
+
// Flatten entries from matching custom lists
|
|
132
|
+
const allEntries = [];
|
|
133
|
+
for (const list of customLists) {
|
|
134
|
+
allEntries.push(...list.entries);
|
|
135
|
+
}
|
|
136
|
+
if (!allEntries.length) {
|
|
137
|
+
const listLabel = args.customListName
|
|
138
|
+
? `custom list "${args.customListName}"`
|
|
139
|
+
: "custom lists";
|
|
140
|
+
return `${username}'s ${listLabel} have no entries.`;
|
|
141
|
+
}
|
|
142
|
+
sortEntries(allEntries, args.sort);
|
|
143
|
+
// Detect score format
|
|
144
|
+
const scoreFormat = await detectScoreFormat(async () => {
|
|
145
|
+
const data = await anilistClient.query(USER_STATS_QUERY, { name: username }, { cache: "stats" });
|
|
146
|
+
return data.User.mediaListOptions.scoreFormat;
|
|
147
|
+
});
|
|
148
|
+
const totalCount = allEntries.length;
|
|
149
|
+
const offset = (args.page - 1) * args.limit;
|
|
150
|
+
const limited = allEntries.slice(offset, offset + args.limit);
|
|
151
|
+
const hasNextPage = offset + args.limit < totalCount;
|
|
152
|
+
const listLabel = args.customListName
|
|
153
|
+
? `custom list "${args.customListName}"`
|
|
154
|
+
: `custom lists (${customLists.length} lists)`;
|
|
155
|
+
const header = `${username}'s ${args.type} ${listLabel} - ${totalCount} entries` +
|
|
156
|
+
(totalCount > limited.length ? `, showing ${limited.length}` : "");
|
|
157
|
+
const formatted = limited.map((entry, i) => formatListEntry(entry, offset + i + 1, scoreFormat));
|
|
158
|
+
const footer = paginationFooter(args.page, args.limit, totalCount, hasNextPage);
|
|
159
|
+
return header + "\n\n" + formatted.join("\n\n") + (footer ? `\n\n${footer}` : "");
|
|
160
|
+
}
|
|
110
161
|
/** Format statistics for a single media type (anime or manga) */
|
|
111
162
|
function formatTypeStats(stats, label) {
|
|
112
163
|
const lines = [`## ${label}`];
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/** Social tools: activity feed, user profiles, and community reviews. */
|
|
2
|
+
import { anilistClient } from "../api/client.js";
|
|
3
|
+
import { ACTIVITY_FEED_QUERY, USER_PROFILE_QUERY, USER_STATS_QUERY, MEDIA_REVIEWS_QUERY, } from "../api/queries.js";
|
|
4
|
+
import { FeedInputSchema, ProfileInputSchema, ReviewsInputSchema, } from "../schemas.js";
|
|
5
|
+
import { getTitle, getDefaultUsername, truncateDescription, throwToolError, paginationFooter, } from "../utils.js";
|
|
6
|
+
/** Register social and community tools */
|
|
7
|
+
export function registerSocialTools(server) {
|
|
8
|
+
// === Activity Feed ===
|
|
9
|
+
server.addTool({
|
|
10
|
+
name: "anilist_feed",
|
|
11
|
+
description: "Get recent activity from a user's AniList feed. " +
|
|
12
|
+
"Shows text posts and list updates (anime/manga status changes). " +
|
|
13
|
+
"Defaults to the configured username if not provided.",
|
|
14
|
+
parameters: FeedInputSchema,
|
|
15
|
+
annotations: {
|
|
16
|
+
title: "Activity Feed",
|
|
17
|
+
readOnlyHint: true,
|
|
18
|
+
destructiveHint: false,
|
|
19
|
+
openWorldHint: true,
|
|
20
|
+
},
|
|
21
|
+
execute: async (args) => {
|
|
22
|
+
try {
|
|
23
|
+
const username = getDefaultUsername(args.username);
|
|
24
|
+
// Resolve username to numeric ID for the activity query
|
|
25
|
+
const userData = await anilistClient.query(USER_STATS_QUERY, { name: username }, { cache: "stats" });
|
|
26
|
+
const userId = userData.User.id;
|
|
27
|
+
const variables = {
|
|
28
|
+
userId,
|
|
29
|
+
page: args.page,
|
|
30
|
+
perPage: args.limit,
|
|
31
|
+
};
|
|
32
|
+
if (args.type !== "ALL")
|
|
33
|
+
variables.type = args.type;
|
|
34
|
+
const data = await anilistClient.query(ACTIVITY_FEED_QUERY, variables, { cache: "search" });
|
|
35
|
+
const { activities, pageInfo } = data.Page;
|
|
36
|
+
if (!activities.length) {
|
|
37
|
+
return `No recent activity for ${username}.`;
|
|
38
|
+
}
|
|
39
|
+
const header = `Activity feed for ${username}`;
|
|
40
|
+
const lines = activities.map((a, i) => formatActivity(a, i + 1));
|
|
41
|
+
const footer = paginationFooter(args.page, args.limit, pageInfo.total, pageInfo.hasNextPage);
|
|
42
|
+
return [header, "", ...lines].join("\n") +
|
|
43
|
+
(footer ? `\n\n${footer}` : "");
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return throwToolError(error, "fetching activity feed");
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
// === User Profile ===
|
|
51
|
+
server.addTool({
|
|
52
|
+
name: "anilist_profile",
|
|
53
|
+
description: "View a user's AniList profile including bio, stats, and favourites. " +
|
|
54
|
+
"Defaults to the configured username if not provided.",
|
|
55
|
+
parameters: ProfileInputSchema,
|
|
56
|
+
annotations: {
|
|
57
|
+
title: "User Profile",
|
|
58
|
+
readOnlyHint: true,
|
|
59
|
+
destructiveHint: false,
|
|
60
|
+
openWorldHint: true,
|
|
61
|
+
},
|
|
62
|
+
execute: async (args) => {
|
|
63
|
+
try {
|
|
64
|
+
const username = getDefaultUsername(args.username);
|
|
65
|
+
const data = await anilistClient.query(USER_PROFILE_QUERY, { name: username }, { cache: "stats" });
|
|
66
|
+
const user = data.User;
|
|
67
|
+
const lines = [`# ${user.name}`, user.siteUrl, ""];
|
|
68
|
+
// About/bio
|
|
69
|
+
if (user.about) {
|
|
70
|
+
lines.push(truncateDescription(user.about, 500), "");
|
|
71
|
+
}
|
|
72
|
+
// Anime stats
|
|
73
|
+
const a = user.statistics.anime;
|
|
74
|
+
if (a.count > 0) {
|
|
75
|
+
const days = (a.minutesWatched / 1440).toFixed(1);
|
|
76
|
+
lines.push(`## Anime: ${a.count} titles | ${a.episodesWatched} episodes | ${days} days | Mean ${a.meanScore.toFixed(1)}`);
|
|
77
|
+
}
|
|
78
|
+
// Manga stats
|
|
79
|
+
const m = user.statistics.manga;
|
|
80
|
+
if (m.count > 0) {
|
|
81
|
+
lines.push(`## Manga: ${m.count} titles | ${m.chaptersRead} chapters | ${m.volumesRead} volumes | Mean ${m.meanScore.toFixed(1)}`);
|
|
82
|
+
}
|
|
83
|
+
// Favorites
|
|
84
|
+
const fav = user.favourites;
|
|
85
|
+
if (fav.anime.nodes.length) {
|
|
86
|
+
lines.push("", "Favourite Anime: " +
|
|
87
|
+
fav.anime.nodes.map((n) => getTitle(n.title)).join(", "));
|
|
88
|
+
}
|
|
89
|
+
if (fav.manga.nodes.length) {
|
|
90
|
+
lines.push("Favourite Manga: " +
|
|
91
|
+
fav.manga.nodes.map((n) => getTitle(n.title)).join(", "));
|
|
92
|
+
}
|
|
93
|
+
if (fav.characters.nodes.length) {
|
|
94
|
+
lines.push("Favourite Characters: " +
|
|
95
|
+
fav.characters.nodes.map((n) => n.name.full).join(", "));
|
|
96
|
+
}
|
|
97
|
+
if (fav.staff.nodes.length) {
|
|
98
|
+
lines.push("Favourite Staff: " +
|
|
99
|
+
fav.staff.nodes.map((n) => n.name.full).join(", "));
|
|
100
|
+
}
|
|
101
|
+
if (fav.studios.nodes.length) {
|
|
102
|
+
lines.push("Favourite Studios: " +
|
|
103
|
+
fav.studios.nodes.map((n) => n.name).join(", "));
|
|
104
|
+
}
|
|
105
|
+
// Account age
|
|
106
|
+
const created = new Date(user.createdAt * 1000).toLocaleDateString("en-US", { month: "short", year: "numeric" });
|
|
107
|
+
lines.push("", `Member since ${created}`);
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
return throwToolError(error, "fetching profile");
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
// === Reviews ===
|
|
116
|
+
const REVIEW_SORT_MAP = {
|
|
117
|
+
HELPFUL: ["RATING_DESC"],
|
|
118
|
+
NEWEST: ["CREATED_AT_DESC"],
|
|
119
|
+
};
|
|
120
|
+
server.addTool({
|
|
121
|
+
name: "anilist_reviews",
|
|
122
|
+
description: "Get community reviews for an anime or manga. " +
|
|
123
|
+
"Shows review scores, summaries, and a sentiment overview. " +
|
|
124
|
+
"Use when the user wants to see what others think about a title.",
|
|
125
|
+
parameters: ReviewsInputSchema,
|
|
126
|
+
annotations: {
|
|
127
|
+
title: "Community Reviews",
|
|
128
|
+
readOnlyHint: true,
|
|
129
|
+
destructiveHint: false,
|
|
130
|
+
openWorldHint: true,
|
|
131
|
+
},
|
|
132
|
+
execute: async (args) => {
|
|
133
|
+
try {
|
|
134
|
+
const variables = {
|
|
135
|
+
page: args.page,
|
|
136
|
+
perPage: args.limit,
|
|
137
|
+
sort: REVIEW_SORT_MAP[args.sort],
|
|
138
|
+
};
|
|
139
|
+
if (args.id)
|
|
140
|
+
variables.id = args.id;
|
|
141
|
+
if (args.title)
|
|
142
|
+
variables.search = args.title;
|
|
143
|
+
const data = await anilistClient.query(MEDIA_REVIEWS_QUERY, variables, { cache: "media" });
|
|
144
|
+
const media = data.Media;
|
|
145
|
+
const title = getTitle(media.title);
|
|
146
|
+
const { nodes, pageInfo } = media.reviews;
|
|
147
|
+
if (!nodes.length) {
|
|
148
|
+
return `No reviews found for ${title}.`;
|
|
149
|
+
}
|
|
150
|
+
// Sentiment summary
|
|
151
|
+
const avgScore = Math.round(nodes.reduce((sum, r) => sum + r.score, 0) / nodes.length);
|
|
152
|
+
const sentiment = avgScore >= 75
|
|
153
|
+
? "Generally positive"
|
|
154
|
+
: avgScore >= 50
|
|
155
|
+
? "Mixed"
|
|
156
|
+
: "Generally negative";
|
|
157
|
+
const header = `Reviews for ${title} - ${sentiment} (avg ${avgScore}/100 across ${pageInfo.total} reviews)`;
|
|
158
|
+
const formatted = nodes.map((r, i) => {
|
|
159
|
+
const date = new Date(r.createdAt * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
160
|
+
const helpful = r.ratingAmount > 0
|
|
161
|
+
? `${r.rating}/${r.ratingAmount} found helpful`
|
|
162
|
+
: "No votes";
|
|
163
|
+
const body = truncateDescription(r.body, 300);
|
|
164
|
+
return [
|
|
165
|
+
`${i + 1}. ${r.score}/100 by ${r.user.name} (${date})`,
|
|
166
|
+
` ${r.summary}`,
|
|
167
|
+
` ${body}`,
|
|
168
|
+
` ${helpful}`,
|
|
169
|
+
].join("\n");
|
|
170
|
+
});
|
|
171
|
+
const footer = paginationFooter(args.page, args.limit, pageInfo.total, pageInfo.hasNextPage);
|
|
172
|
+
return [header, "", ...formatted].join("\n\n") +
|
|
173
|
+
(footer ? `\n\n${footer}` : "");
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
return throwToolError(error, "fetching reviews");
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// === Formatting Helpers ===
|
|
182
|
+
/** Format a single activity entry */
|
|
183
|
+
function formatActivity(activity, index) {
|
|
184
|
+
const date = new Date(activity.createdAt * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
185
|
+
if (activity.__typename === "TextActivity") {
|
|
186
|
+
const text = activity.text.length > 200
|
|
187
|
+
? activity.text.slice(0, 200) + "..."
|
|
188
|
+
: activity.text;
|
|
189
|
+
return `${index}. ${activity.user.name} posted (${date}):\n ${text}`;
|
|
190
|
+
}
|
|
191
|
+
// List activity
|
|
192
|
+
const title = getTitle(activity.media.title);
|
|
193
|
+
const progress = activity.progress ? ` ${activity.progress}` : "";
|
|
194
|
+
return `${index}. ${activity.user.name} ${activity.status}${progress} ${title} (${date})`;
|
|
195
|
+
}
|
package/dist/tools/write.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Write tools:
|
|
1
|
+
/** Write tools: list mutations, favourites, and activity posting. */
|
|
2
2
|
import type { FastMCP } from "fastmcp";
|
|
3
3
|
/** Register list mutation tools */
|
|
4
4
|
export declare function registerWriteTools(server: FastMCP): void;
|
package/dist/tools/write.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** Write tools:
|
|
1
|
+
/** Write tools: list mutations, favourites, and activity posting. */
|
|
2
2
|
import { anilistClient } from "../api/client.js";
|
|
3
|
-
import { SAVE_MEDIA_LIST_ENTRY_MUTATION, DELETE_MEDIA_LIST_ENTRY_MUTATION, VIEWER_QUERY, } from "../api/queries.js";
|
|
4
|
-
import { UpdateProgressInputSchema, AddToListInputSchema, RateInputSchema, DeleteFromListInputSchema, } from "../schemas.js";
|
|
3
|
+
import { SAVE_MEDIA_LIST_ENTRY_MUTATION, DELETE_MEDIA_LIST_ENTRY_MUTATION, TOGGLE_FAVOURITE_MUTATION, SAVE_TEXT_ACTIVITY_MUTATION, VIEWER_QUERY, } from "../api/queries.js";
|
|
4
|
+
import { UpdateProgressInputSchema, AddToListInputSchema, RateInputSchema, DeleteFromListInputSchema, FavouriteInputSchema, PostActivityInputSchema, } from "../schemas.js";
|
|
5
5
|
import { throwToolError, formatScore, detectScoreFormat } from "../utils.js";
|
|
6
6
|
// === Auth Guard ===
|
|
7
7
|
/** Guard against unauthenticated write attempts */
|
|
@@ -160,4 +160,86 @@ export function registerWriteTools(server) {
|
|
|
160
160
|
}
|
|
161
161
|
},
|
|
162
162
|
});
|
|
163
|
+
// === Toggle Favourite ===
|
|
164
|
+
// Map entity type to mutation variable name
|
|
165
|
+
const FAVOURITE_VAR_MAP = {
|
|
166
|
+
ANIME: "animeId",
|
|
167
|
+
MANGA: "mangaId",
|
|
168
|
+
CHARACTER: "characterId",
|
|
169
|
+
STAFF: "staffId",
|
|
170
|
+
STUDIO: "studioId",
|
|
171
|
+
};
|
|
172
|
+
// Map entity type to response field name
|
|
173
|
+
const FAVOURITE_FIELD_MAP = {
|
|
174
|
+
ANIME: "anime",
|
|
175
|
+
MANGA: "manga",
|
|
176
|
+
CHARACTER: "characters",
|
|
177
|
+
STAFF: "staff",
|
|
178
|
+
STUDIO: "studios",
|
|
179
|
+
};
|
|
180
|
+
server.addTool({
|
|
181
|
+
name: "anilist_favourite",
|
|
182
|
+
description: "Toggle favourite on an anime, manga, character, staff member, or studio. " +
|
|
183
|
+
"Calling again on the same entity removes it from favourites. " +
|
|
184
|
+
"Requires ANILIST_TOKEN.",
|
|
185
|
+
parameters: FavouriteInputSchema,
|
|
186
|
+
annotations: {
|
|
187
|
+
title: "Toggle Favourite",
|
|
188
|
+
readOnlyHint: false,
|
|
189
|
+
destructiveHint: true,
|
|
190
|
+
idempotentHint: false,
|
|
191
|
+
openWorldHint: true,
|
|
192
|
+
},
|
|
193
|
+
execute: async (args) => {
|
|
194
|
+
try {
|
|
195
|
+
requireAuth();
|
|
196
|
+
const variables = { [FAVOURITE_VAR_MAP[args.type]]: args.id };
|
|
197
|
+
const data = await anilistClient.query(TOGGLE_FAVOURITE_MUTATION, variables, { cache: null });
|
|
198
|
+
anilistClient.clearCache();
|
|
199
|
+
// Check if entity is now in favourites (added) or absent (removed)
|
|
200
|
+
const field = FAVOURITE_FIELD_MAP[args.type];
|
|
201
|
+
const isFavourited = data.ToggleFavourite[field].nodes.some((n) => n.id === args.id);
|
|
202
|
+
const label = args.type.toLowerCase();
|
|
203
|
+
return isFavourited
|
|
204
|
+
? `Added ${label} ${args.id} to favourites.`
|
|
205
|
+
: `Removed ${label} ${args.id} from favourites.`;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
return throwToolError(error, "toggling favourite");
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
// === Post Activity ===
|
|
213
|
+
server.addTool({
|
|
214
|
+
name: "anilist_activity",
|
|
215
|
+
description: "Post a text activity to your AniList feed. " +
|
|
216
|
+
"Use when the user wants to share a status update, thought, or message. " +
|
|
217
|
+
"Requires ANILIST_TOKEN.",
|
|
218
|
+
parameters: PostActivityInputSchema,
|
|
219
|
+
annotations: {
|
|
220
|
+
title: "Post Activity",
|
|
221
|
+
readOnlyHint: false,
|
|
222
|
+
destructiveHint: true,
|
|
223
|
+
idempotentHint: false,
|
|
224
|
+
openWorldHint: true,
|
|
225
|
+
},
|
|
226
|
+
execute: async (args) => {
|
|
227
|
+
try {
|
|
228
|
+
requireAuth();
|
|
229
|
+
const data = await anilistClient.query(SAVE_TEXT_ACTIVITY_MUTATION, { text: args.text }, { cache: null });
|
|
230
|
+
anilistClient.clearCache();
|
|
231
|
+
const activity = data.SaveTextActivity;
|
|
232
|
+
const dateStr = new Date(activity.createdAt * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
233
|
+
return [
|
|
234
|
+
`Activity posted.`,
|
|
235
|
+
`By: ${activity.user.name}`,
|
|
236
|
+
`Date: ${dateStr}`,
|
|
237
|
+
`Activity ID: ${activity.id}`,
|
|
238
|
+
].join("\n");
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
return throwToolError(error, "posting activity");
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
});
|
|
163
245
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -315,6 +315,7 @@ export interface UserListResponse {
|
|
|
315
315
|
lists: Array<{
|
|
316
316
|
name: string;
|
|
317
317
|
status: string;
|
|
318
|
+
isCustomList: boolean;
|
|
318
319
|
entries: AniListMediaListEntry[];
|
|
319
320
|
}>;
|
|
320
321
|
};
|
|
@@ -379,6 +380,199 @@ export interface GenreTagCollectionResponse {
|
|
|
379
380
|
isAdult: boolean;
|
|
380
381
|
}>;
|
|
381
382
|
}
|
|
383
|
+
/** Response from toggling a favourite */
|
|
384
|
+
export interface ToggleFavouriteResponse {
|
|
385
|
+
ToggleFavourite: {
|
|
386
|
+
anime: {
|
|
387
|
+
nodes: Array<{
|
|
388
|
+
id: number;
|
|
389
|
+
}>;
|
|
390
|
+
};
|
|
391
|
+
manga: {
|
|
392
|
+
nodes: Array<{
|
|
393
|
+
id: number;
|
|
394
|
+
}>;
|
|
395
|
+
};
|
|
396
|
+
characters: {
|
|
397
|
+
nodes: Array<{
|
|
398
|
+
id: number;
|
|
399
|
+
}>;
|
|
400
|
+
};
|
|
401
|
+
staff: {
|
|
402
|
+
nodes: Array<{
|
|
403
|
+
id: number;
|
|
404
|
+
}>;
|
|
405
|
+
};
|
|
406
|
+
studios: {
|
|
407
|
+
nodes: Array<{
|
|
408
|
+
id: number;
|
|
409
|
+
}>;
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/** Response from posting a text activity */
|
|
414
|
+
export interface SaveTextActivityResponse {
|
|
415
|
+
SaveTextActivity: {
|
|
416
|
+
id: number;
|
|
417
|
+
createdAt: number;
|
|
418
|
+
text: string;
|
|
419
|
+
user: {
|
|
420
|
+
name: string;
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** Text-based activity on a user's feed */
|
|
425
|
+
export interface TextActivity {
|
|
426
|
+
__typename: "TextActivity";
|
|
427
|
+
id: number;
|
|
428
|
+
text: string;
|
|
429
|
+
createdAt: number;
|
|
430
|
+
user: {
|
|
431
|
+
name: string;
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/** List update activity on a user's feed */
|
|
435
|
+
export interface ListActivity {
|
|
436
|
+
__typename: "ListActivity";
|
|
437
|
+
id: number;
|
|
438
|
+
status: string;
|
|
439
|
+
progress: string | null;
|
|
440
|
+
createdAt: number;
|
|
441
|
+
user: {
|
|
442
|
+
name: string;
|
|
443
|
+
};
|
|
444
|
+
media: {
|
|
445
|
+
id: number;
|
|
446
|
+
title: {
|
|
447
|
+
romaji: string | null;
|
|
448
|
+
english: string | null;
|
|
449
|
+
native: string | null;
|
|
450
|
+
};
|
|
451
|
+
type: string;
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/** Union of activity types returned by the feed query */
|
|
455
|
+
export type Activity = TextActivity | ListActivity;
|
|
456
|
+
/** Paginated activity feed response */
|
|
457
|
+
export interface ActivityFeedResponse {
|
|
458
|
+
Page: {
|
|
459
|
+
pageInfo: {
|
|
460
|
+
total: number;
|
|
461
|
+
currentPage: number;
|
|
462
|
+
hasNextPage: boolean;
|
|
463
|
+
};
|
|
464
|
+
activities: Activity[];
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/** User profile with bio, stats, and favourites */
|
|
468
|
+
export interface UserProfileResponse {
|
|
469
|
+
User: {
|
|
470
|
+
id: number;
|
|
471
|
+
name: string;
|
|
472
|
+
about: string | null;
|
|
473
|
+
avatar: {
|
|
474
|
+
large: string | null;
|
|
475
|
+
};
|
|
476
|
+
bannerImage: string | null;
|
|
477
|
+
siteUrl: string;
|
|
478
|
+
createdAt: number;
|
|
479
|
+
updatedAt: number;
|
|
480
|
+
donatorTier: number;
|
|
481
|
+
statistics: {
|
|
482
|
+
anime: {
|
|
483
|
+
count: number;
|
|
484
|
+
meanScore: number;
|
|
485
|
+
episodesWatched: number;
|
|
486
|
+
minutesWatched: number;
|
|
487
|
+
};
|
|
488
|
+
manga: {
|
|
489
|
+
count: number;
|
|
490
|
+
meanScore: number;
|
|
491
|
+
chaptersRead: number;
|
|
492
|
+
volumesRead: number;
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
favourites: {
|
|
496
|
+
anime: {
|
|
497
|
+
nodes: Array<{
|
|
498
|
+
id: number;
|
|
499
|
+
title: {
|
|
500
|
+
romaji: string | null;
|
|
501
|
+
english: string | null;
|
|
502
|
+
native: string | null;
|
|
503
|
+
};
|
|
504
|
+
siteUrl: string;
|
|
505
|
+
}>;
|
|
506
|
+
};
|
|
507
|
+
manga: {
|
|
508
|
+
nodes: Array<{
|
|
509
|
+
id: number;
|
|
510
|
+
title: {
|
|
511
|
+
romaji: string | null;
|
|
512
|
+
english: string | null;
|
|
513
|
+
native: string | null;
|
|
514
|
+
};
|
|
515
|
+
siteUrl: string;
|
|
516
|
+
}>;
|
|
517
|
+
};
|
|
518
|
+
characters: {
|
|
519
|
+
nodes: Array<{
|
|
520
|
+
id: number;
|
|
521
|
+
name: {
|
|
522
|
+
full: string;
|
|
523
|
+
};
|
|
524
|
+
siteUrl: string;
|
|
525
|
+
}>;
|
|
526
|
+
};
|
|
527
|
+
staff: {
|
|
528
|
+
nodes: Array<{
|
|
529
|
+
id: number;
|
|
530
|
+
name: {
|
|
531
|
+
full: string;
|
|
532
|
+
};
|
|
533
|
+
siteUrl: string;
|
|
534
|
+
}>;
|
|
535
|
+
};
|
|
536
|
+
studios: {
|
|
537
|
+
nodes: Array<{
|
|
538
|
+
id: number;
|
|
539
|
+
name: string;
|
|
540
|
+
siteUrl: string;
|
|
541
|
+
}>;
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/** Community reviews for a media title */
|
|
547
|
+
export interface MediaReviewsResponse {
|
|
548
|
+
Media: {
|
|
549
|
+
id: number;
|
|
550
|
+
title: {
|
|
551
|
+
romaji: string | null;
|
|
552
|
+
english: string | null;
|
|
553
|
+
native: string | null;
|
|
554
|
+
};
|
|
555
|
+
reviews: {
|
|
556
|
+
pageInfo: {
|
|
557
|
+
total: number;
|
|
558
|
+
hasNextPage: boolean;
|
|
559
|
+
};
|
|
560
|
+
nodes: Array<{
|
|
561
|
+
id: number;
|
|
562
|
+
score: number;
|
|
563
|
+
summary: string;
|
|
564
|
+
body: string;
|
|
565
|
+
rating: number;
|
|
566
|
+
ratingAmount: number;
|
|
567
|
+
createdAt: number;
|
|
568
|
+
user: {
|
|
569
|
+
name: string;
|
|
570
|
+
siteUrl: string;
|
|
571
|
+
};
|
|
572
|
+
}>;
|
|
573
|
+
};
|
|
574
|
+
};
|
|
575
|
+
}
|
|
382
576
|
/** Single studio with production history */
|
|
383
577
|
export interface StudioSearchResponse {
|
|
384
578
|
Studio: {
|
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.4.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.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ani-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|