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.
@@ -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 */
@@ -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, flattened into a single array */
91
- async fetchList(username, type, status, sort) {
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 data.MediaListCollection.lists) {
105
+ for (const list of lists) {
101
106
  entries.push(...list.entries);
102
107
  }
103
108
  return entries;
@@ -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";
@@ -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.1.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
+ });
@@ -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,4 @@
1
+ /** Social tools: activity feed, user profiles, and community reviews. */
2
+ import type { FastMCP } from "fastmcp";
3
+ /** Register social and community tools */
4
+ export declare function registerSocialTools(server: FastMCP): void;
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- /** Write tools: update progress, add to list, rate, and delete entries. */
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;
@@ -1,7 +1,7 @@
1
- /** Write tools: update progress, add to list, rate, and delete entries. */
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ani-mcp",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "display_name": "AniList MCP",
6
6
  "description": "A smart MCP server for AniList that gets your anime/manga taste - not just API calls.",
7
7
  "author": {
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.3.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.3.0",
9
+ "version": "0.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ani-mcp",
14
- "version": "0.3.0",
14
+ "version": "0.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },