ani-mcp 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,8 @@ export declare const CACHE_TTLS: {
12
12
  readonly list: number;
13
13
  readonly seasonal: number;
14
14
  readonly stats: number;
15
+ readonly trending: number;
16
+ readonly schedule: number;
15
17
  };
16
18
  export type CacheCategory = keyof typeof CACHE_TTLS;
17
19
  /** API error with HTTP status and retry eligibility */
@@ -37,6 +39,8 @@ declare class AniListClient {
37
39
  fetchList(username: string, type: string, status?: string, sort?: string[]): Promise<AniListMediaListEntry[]>;
38
40
  /** Invalidate the entire query cache */
39
41
  clearCache(): void;
42
+ /** Evict cache entries related to a specific user (lists and stats) */
43
+ invalidateUser(username: string): void;
40
44
  /** Retries with exponential backoff via p-retry */
41
45
  private executeWithRetry;
42
46
  /** Send a single GraphQL POST request and parse the response */
@@ -34,6 +34,8 @@ export const CACHE_TTLS = {
34
34
  list: 5 * 60 * 1000, // 5m
35
35
  seasonal: 30 * 60 * 1000, // 30m
36
36
  stats: 10 * 60 * 1000, // 10m
37
+ trending: 30 * 60 * 1000, // 30m
38
+ schedule: 30 * 60 * 1000, // 30m
37
39
  };
38
40
  // 85 req/60s, excess calls queue automatically
39
41
  const rateLimit = pThrottle({
@@ -46,6 +48,14 @@ const queryCache = new LRUCache({
46
48
  max: 500,
47
49
  allowStale: false,
48
50
  });
51
+ /** Stable JSON serialization with sorted keys */
52
+ function stableStringify(obj) {
53
+ const keys = Object.keys(obj).sort();
54
+ const sorted = {};
55
+ for (const k of keys)
56
+ sorted[k] = obj[k];
57
+ return JSON.stringify(sorted);
58
+ }
49
59
  // === Error Types ===
50
60
  /** API error with HTTP status and retry eligibility */
51
61
  export class AniListApiError extends Error {
@@ -71,7 +81,7 @@ class AniListClient {
71
81
  const name = queryName(query);
72
82
  // Cache-through: return cached result or fetch, store, and return
73
83
  if (cacheCategory) {
74
- const cacheKey = `${query}::${JSON.stringify(variables)}`;
84
+ const cacheKey = `${query}::${stableStringify(variables)}`;
75
85
  const cached = queryCache.get(cacheKey);
76
86
  if (cached !== undefined) {
77
87
  log("cache-hit", name);
@@ -111,6 +121,17 @@ class AniListClient {
111
121
  clearCache() {
112
122
  queryCache.clear();
113
123
  }
124
+ /** Evict cache entries related to a specific user (lists and stats) */
125
+ invalidateUser(username) {
126
+ const needle = `"${username}"`;
127
+ for (const key of queryCache.keys()) {
128
+ // Variable portion is after "::"
129
+ const varPart = key.slice(key.indexOf("::") + 2);
130
+ if (varPart.includes(needle)) {
131
+ queryCache.delete(key);
132
+ }
133
+ }
134
+ }
114
135
  /** Retries with exponential backoff via p-retry */
115
136
  async executeWithRetry(query, variables) {
116
137
  const name = queryName(query);
@@ -35,18 +35,21 @@ export function computeGenreDivergences(p1, p2, name1 = "User 1", name2 = "User
35
35
  const genres2 = new Map(p2.genres.map((g) => [g.name, g]));
36
36
  const allGenres = new Set([...genres1.keys(), ...genres2.keys()]);
37
37
  const divergences = [];
38
+ // Pre-compute rank maps for O(1) lookup
39
+ const rankOf1 = new Map([...genres1.keys()].map((g, i) => [g, i]));
40
+ const rankOf2 = new Map([...genres2.keys()].map((g, i) => [g, i]));
38
41
  // Flag genres in one user's top 5 but not the other's top 10
39
42
  for (const genre of allGenres) {
40
- const rank1 = [...genres1.keys()].indexOf(genre);
41
- const rank2 = [...genres2.keys()].indexOf(genre);
42
- if (rank1 >= 0 && rank1 < 5 && (rank2 < 0 || rank2 > 10)) {
43
+ const rank1 = rankOf1.get(genre) ?? -1;
44
+ const rank2 = rankOf2.get(genre) ?? -1;
45
+ if (rank1 >= 0 && rank1 < 5 && (rank2 === -1 || rank2 > 10)) {
43
46
  divergences.push({
44
47
  genre,
45
48
  diff: 10,
46
49
  desc: `${name1} loves ${genre}, ${name2} doesn't`,
47
50
  });
48
51
  }
49
- else if (rank2 >= 0 && rank2 < 5 && (rank1 < 0 || rank1 > 10)) {
52
+ else if (rank2 >= 0 && rank2 < 5 && (rank1 === -1 || rank1 > 10)) {
50
53
  divergences.push({
51
54
  genre,
52
55
  diff: 10,
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ if (!process.env.ANILIST_TOKEN) {
21
21
  }
22
22
  const server = new FastMCP({
23
23
  name: "ani-mcp",
24
- version: "0.8.0",
24
+ version: "0.8.2",
25
25
  });
26
26
  registerSearchTools(server);
27
27
  registerListTools(server);
package/dist/resources.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /** MCP Resources: expose user context without tool calls */
2
2
  import { anilistClient } from "./api/client.js";
3
- import { USER_PROFILE_QUERY, USER_STATS_QUERY } from "./api/queries.js";
3
+ import { USER_PROFILE_QUERY } from "./api/queries.js";
4
4
  import { buildTasteProfile, describeTasteProfile, } from "./engine/taste.js";
5
5
  import { formatProfile } from "./tools/social.js";
6
6
  import { formatListEntry } from "./tools/lists.js";
7
- import { getDefaultUsername, detectScoreFormat } from "./utils.js";
7
+ import { getDefaultUsername, getScoreFormat } from "./utils.js";
8
8
  /** Register MCP resources on the server */
9
9
  export function registerResources(server) {
10
10
  // === User Profile ===
@@ -73,10 +73,7 @@ export function registerResources(server) {
73
73
  const mediaType = String(type).toUpperCase();
74
74
  const [entries, scoreFormat] = await Promise.all([
75
75
  anilistClient.fetchList(username, mediaType, "CURRENT"),
76
- detectScoreFormat(async () => {
77
- const data = await anilistClient.query(USER_STATS_QUERY, { name: username }, { cache: "stats" });
78
- return data.User.mediaListOptions.scoreFormat;
79
- }),
76
+ getScoreFormat(username),
80
77
  ]);
81
78
  if (!entries.length) {
82
79
  return {
package/dist/schemas.d.ts CHANGED
@@ -278,11 +278,13 @@ export declare const RateInputSchema: z.ZodObject<{
278
278
  export type RateInput = z.infer<typeof RateInputSchema>;
279
279
  /** Input for removing a title from the list */
280
280
  export declare const DeleteFromListInputSchema: z.ZodObject<{
281
- entryId: z.ZodNumber;
281
+ entryId: z.ZodOptional<z.ZodNumber>;
282
+ mediaId: z.ZodOptional<z.ZodNumber>;
282
283
  }, z.core.$strip>;
283
284
  /** Input for scoring a title against a user's taste profile */
284
285
  export declare const ExplainInputSchema: z.ZodObject<{
285
- mediaId: z.ZodNumber;
286
+ mediaId: z.ZodOptional<z.ZodNumber>;
287
+ title: z.ZodOptional<z.ZodString>;
286
288
  username: z.ZodOptional<z.ZodString>;
287
289
  type: z.ZodDefault<z.ZodEnum<{
288
290
  ANIME: "ANIME";
@@ -294,7 +296,8 @@ export declare const ExplainInputSchema: z.ZodObject<{
294
296
  export type ExplainInput = z.infer<typeof ExplainInputSchema>;
295
297
  /** Input for finding titles similar to a specific anime or manga */
296
298
  export declare const SimilarInputSchema: z.ZodObject<{
297
- mediaId: z.ZodNumber;
299
+ mediaId: z.ZodOptional<z.ZodNumber>;
300
+ title: z.ZodOptional<z.ZodString>;
298
301
  limit: z.ZodDefault<z.ZodNumber>;
299
302
  }, z.core.$strip>;
300
303
  export type SimilarInput = z.infer<typeof SimilarInputSchema>;
package/dist/schemas.js CHANGED
@@ -7,12 +7,12 @@ const pageParam = z
7
7
  .min(1)
8
8
  .default(1)
9
9
  .describe("Page number for pagination (default 1)");
10
- // AniList usernames: 2-20 chars, alphanumeric + underscores
10
+ // AniList usernames: 2-20 chars, alphanumeric + underscores + hyphens
11
11
  const usernameSchema = z
12
12
  .string()
13
13
  .min(2)
14
14
  .max(20)
15
- .regex(/^[a-zA-Z0-9_]+$/, "Letters, numbers, and underscores only");
15
+ .regex(/^[a-zA-Z0-9_-]+$/, "Letters, numbers, underscores, and hyphens only");
16
16
  /** Input for searching anime or manga by title and filters */
17
17
  export const SearchInputSchema = z.object({
18
18
  query: z
@@ -471,21 +471,37 @@ export const RateInputSchema = z.object({
471
471
  .describe("Score on a 0-10 scale. Use 0 to remove a score."),
472
472
  });
473
473
  /** Input for removing a title from the list */
474
- export const DeleteFromListInputSchema = z.object({
474
+ export const DeleteFromListInputSchema = z
475
+ .object({
475
476
  entryId: z
476
477
  .number()
477
478
  .int()
478
479
  .positive()
479
- .describe("List entry ID to delete. This is the id field on a list entry, not the media ID. " +
480
- "Use anilist_list to find entry IDs."),
480
+ .optional()
481
+ .describe("List entry ID to delete (from anilist_list)"),
482
+ mediaId: z
483
+ .number()
484
+ .int()
485
+ .positive()
486
+ .optional()
487
+ .describe("AniList media ID to remove from your list"),
488
+ })
489
+ .refine((data) => data.entryId !== undefined || data.mediaId !== undefined, {
490
+ message: "Provide either an entryId or a mediaId.",
481
491
  });
482
492
  /** Input for scoring a title against a user's taste profile */
483
- export const ExplainInputSchema = z.object({
493
+ export const ExplainInputSchema = z
494
+ .object({
484
495
  mediaId: z
485
496
  .number()
486
497
  .int()
487
498
  .positive()
499
+ .optional()
488
500
  .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"),
489
505
  username: usernameSchema
490
506
  .optional()
491
507
  .describe("AniList username. Falls back to configured default if not provided."),
@@ -497,14 +513,23 @@ export const ExplainInputSchema = z.object({
497
513
  .string()
498
514
  .optional()
499
515
  .describe('Optional mood context, e.g. "dark and brainy"'),
516
+ })
517
+ .refine((data) => data.mediaId !== undefined || data.title !== undefined, {
518
+ message: "Provide either a mediaId or a title.",
500
519
  });
501
520
  /** Input for finding titles similar to a specific anime or manga */
502
- export const SimilarInputSchema = z.object({
521
+ export const SimilarInputSchema = z
522
+ .object({
503
523
  mediaId: z
504
524
  .number()
505
525
  .int()
506
526
  .positive()
527
+ .optional()
507
528
  .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"),
508
533
  limit: z
509
534
  .number()
510
535
  .int()
@@ -512,6 +537,9 @@ export const SimilarInputSchema = z.object({
512
537
  .max(25)
513
538
  .default(10)
514
539
  .describe("Number of similar titles to return (default 10, max 25)"),
540
+ })
541
+ .refine((data) => data.mediaId !== undefined || data.title !== undefined, {
542
+ message: "Provide either a mediaId or a title.",
515
543
  });
516
544
  /** Input for searching staff/people by name */
517
545
  export const StaffSearchInputSchema = z.object({
@@ -2,7 +2,7 @@
2
2
  import { anilistClient } from "../api/client.js";
3
3
  import { TRENDING_MEDIA_QUERY, GENRE_BROWSE_QUERY, GENRE_TAG_COLLECTION_QUERY, } from "../api/queries.js";
4
4
  import { TrendingInputSchema, GenreBrowseInputSchema, GenreListInputSchema, } from "../schemas.js";
5
- import { formatMediaSummary, throwToolError, paginationFooter, } from "../utils.js";
5
+ import { formatMediaSummary, throwToolError, paginationFooter, BROWSE_SORT_MAP, } from "../utils.js";
6
6
  /** Register discovery tools on the MCP server */
7
7
  export function registerDiscoverTools(server) {
8
8
  // === Trending ===
@@ -25,7 +25,7 @@ export function registerDiscoverTools(server) {
25
25
  isAdult: args.isAdult ? undefined : false,
26
26
  page: args.page,
27
27
  perPage: args.limit,
28
- }, { cache: "search" });
28
+ }, { cache: "trending" });
29
29
  const results = data.Page.media;
30
30
  if (!results.length) {
31
31
  return `No trending ${args.type.toLowerCase()} found.`;
@@ -61,15 +61,10 @@ export function registerDiscoverTools(server) {
61
61
  },
62
62
  execute: async (args) => {
63
63
  try {
64
- const sortMap = {
65
- SCORE: ["SCORE_DESC"],
66
- POPULARITY: ["POPULARITY_DESC"],
67
- TRENDING: ["TRENDING_DESC"],
68
- };
69
64
  const variables = {
70
65
  type: args.type,
71
66
  genre_in: [args.genre],
72
- sort: sortMap[args.sort] ?? sortMap.SCORE,
67
+ sort: BROWSE_SORT_MAP[args.sort] ?? BROWSE_SORT_MAP.SCORE,
73
68
  isAdult: args.isAdult ? undefined : false,
74
69
  page: args.page,
75
70
  perPage: args.limit,
@@ -154,7 +154,7 @@ export function registerInfoTools(server) {
154
154
  variables.id = args.id;
155
155
  if (args.title)
156
156
  variables.search = args.title;
157
- const data = await anilistClient.query(AIRING_SCHEDULE_QUERY, variables, { cache: "search" });
157
+ const data = await anilistClient.query(AIRING_SCHEDULE_QUERY, variables, { cache: "schedule" });
158
158
  const m = data.Media;
159
159
  const lines = [
160
160
  `# Schedule: ${getTitle(m.title)}`,
@@ -2,7 +2,7 @@
2
2
  import { anilistClient } from "../api/client.js";
3
3
  import { USER_STATS_QUERY } from "../api/queries.js";
4
4
  import { ListInputSchema, StatsInputSchema } from "../schemas.js";
5
- import { getTitle, getDefaultUsername, throwToolError, paginationFooter, formatScore, detectScoreFormat, } from "../utils.js";
5
+ import { getTitle, getDefaultUsername, throwToolError, paginationFooter, formatScore, getScoreFormat, } from "../utils.js";
6
6
  // Map user-friendly sort names to AniList's internal enum values
7
7
  const SORT_MAP = {
8
8
  SCORE: ["SCORE_DESC"],
@@ -38,10 +38,7 @@ export function registerListTools(server) {
38
38
  const status = args.status !== "ALL" ? args.status : undefined;
39
39
  const [allEntries, scoreFormat] = await Promise.all([
40
40
  anilistClient.fetchList(username, args.type, status, sort),
41
- detectScoreFormat(async () => {
42
- const data = await anilistClient.query(USER_STATS_QUERY, { name: username }, { cache: "stats" });
43
- return data.User.mediaListOptions.scoreFormat;
44
- }),
41
+ getScoreFormat(username),
45
42
  ]);
46
43
  if (!allEntries.length) {
47
44
  if (args.status === "ALL") {
@@ -141,11 +138,7 @@ async function handleCustomLists(username, args, sort) {
141
138
  return `${username}'s ${listLabel} have no entries.`;
142
139
  }
143
140
  sortEntries(allEntries, args.sort);
144
- // Detect score format
145
- const scoreFormat = await detectScoreFormat(async () => {
146
- const data = await anilistClient.query(USER_STATS_QUERY, { name: username }, { cache: "stats" });
147
- return data.User.mediaListOptions.scoreFormat;
148
- });
141
+ const scoreFormat = await getScoreFormat(username);
149
142
  const totalCount = allEntries.length;
150
143
  const offset = (args.page - 1) * args.limit;
151
144
  const limited = allEntries.slice(offset, offset + args.limit);
@@ -2,7 +2,8 @@
2
2
  import { anilistClient } from "../api/client.js";
3
3
  import { BATCH_RELATIONS_QUERY, DISCOVER_MEDIA_QUERY, MEDIA_DETAILS_QUERY, RECOMMENDATIONS_QUERY, SEASONAL_MEDIA_QUERY, } from "../api/queries.js";
4
4
  import { TasteInputSchema, PickInputSchema, SessionInputSchema, SequelAlertInputSchema, WatchOrderInputSchema, CompareInputSchema, WrappedInputSchema, ExplainInputSchema, SimilarInputSchema, } from "../schemas.js";
5
- import { getTitle, getDefaultUsername, throwToolError, isNsfwEnabled, resolveSeasonYear, } from "../utils.js";
5
+ import { getTitle, getDefaultUsername, throwToolError, isNsfwEnabled, resolveSeasonYear, resolveAlias, } from "../utils.js";
6
+ import { SEARCH_MEDIA_QUERY } from "../api/queries.js";
6
7
  import { buildTasteProfile, describeTasteProfile, } from "../engine/taste.js";
7
8
  import { matchCandidates, explainMatch } from "../engine/matcher.js";
8
9
  import { parseMood, hasMoodMatch, seasonalMoodSuggestions, } from "../engine/mood.js";
@@ -146,15 +147,19 @@ export function registerRecommendTools(server) {
146
147
  const { season, year } = resolveSeasonYear(args.season, args.year);
147
148
  sourceLabel = `${season} ${year} seasonal anime`;
148
149
  candidatePromise = (async () => {
149
- const data = await anilistClient.query(SEASONAL_MEDIA_QUERY, {
150
+ const vars = {
150
151
  season,
151
152
  seasonYear: year,
152
153
  type: "ANIME",
153
154
  sort: ["POPULARITY_DESC"],
154
155
  perPage: 50,
155
- page: 1,
156
- }, { cache: "seasonal" });
157
- return data.Page.media;
156
+ };
157
+ // Fetch pages 1 and 2 in parallel to cover 100 titles
158
+ const [p1, p2] = await Promise.all([
159
+ anilistClient.query(SEASONAL_MEDIA_QUERY, { ...vars, page: 1 }, { cache: "seasonal" }),
160
+ anilistClient.query(SEASONAL_MEDIA_QUERY, { ...vars, page: 2 }, { cache: "seasonal" }),
161
+ ]);
162
+ return [...p1.Page.media, ...p2.Page.media];
158
163
  })();
159
164
  }
160
165
  else if (source === "DISCOVER") {
@@ -783,11 +788,19 @@ export function registerRecommendTools(server) {
783
788
  execute: async (args) => {
784
789
  try {
785
790
  const username = getDefaultUsername(args.username);
791
+ // Resolve title to media ID if needed
792
+ let mediaId = args.mediaId;
793
+ if (!mediaId && args.title) {
794
+ const search = resolveAlias(args.title);
795
+ const searchData = await anilistClient.query(SEARCH_MEDIA_QUERY, { search, type: "ANIME", page: 1, perPage: 1 }, { cache: "search" });
796
+ if (!searchData.Page.media.length) {
797
+ return `No results found for "${args.title}".`;
798
+ }
799
+ mediaId = searchData.Page.media[0].id;
800
+ }
786
801
  // Fetch media details and taste profile in parallel
787
802
  const [mediaData, { profile, entries }] = await Promise.all([
788
- anilistClient.query(MEDIA_DETAILS_QUERY, {
789
- id: args.mediaId,
790
- }, { cache: "media" }),
803
+ anilistClient.query(MEDIA_DETAILS_QUERY, { id: mediaId }, { cache: "media" }),
791
804
  profileForUser(username, args.type),
792
805
  ]);
793
806
  const media = mediaData.Media;
@@ -874,15 +887,20 @@ export function registerRecommendTools(server) {
874
887
  },
875
888
  execute: async (args) => {
876
889
  try {
890
+ // Resolve title to media ID if needed
891
+ let mediaId = args.mediaId;
892
+ if (!mediaId && args.title) {
893
+ const search = resolveAlias(args.title);
894
+ const searchData = await anilistClient.query(SEARCH_MEDIA_QUERY, { search, type: "ANIME", page: 1, perPage: 1 }, { cache: "search" });
895
+ if (!searchData.Page.media.length) {
896
+ return `No results found for "${args.title}".`;
897
+ }
898
+ mediaId = searchData.Page.media[0].id;
899
+ }
877
900
  // Fetch source details and recommendations in parallel
878
901
  const [detailsData, recsData] = await Promise.all([
879
- anilistClient.query(MEDIA_DETAILS_QUERY, {
880
- id: args.mediaId,
881
- }, { cache: "media" }),
882
- anilistClient.query(RECOMMENDATIONS_QUERY, {
883
- id: args.mediaId,
884
- perPage: 25,
885
- }, { cache: "media" }),
902
+ anilistClient.query(MEDIA_DETAILS_QUERY, { id: mediaId }, { cache: "media" }),
903
+ anilistClient.query(RECOMMENDATIONS_QUERY, { id: mediaId, perPage: 25 }, { cache: "media" }),
886
904
  ]);
887
905
  const source = detailsData.Media;
888
906
  const sourceTitle = getTitle(source.title);
@@ -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, } from "../utils.js";
5
+ import { getTitle, truncateDescription, throwToolError, formatMediaSummary, paginationFooter, isNsfwEnabled, resolveAlias, resolveSeasonYear, 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 ===
@@ -171,17 +171,12 @@ export function registerSearchTools(server) {
171
171
  execute: async (args) => {
172
172
  try {
173
173
  const { season, year } = resolveSeasonYear(args.season, args.year);
174
- const sortMap = {
175
- POPULARITY: ["POPULARITY_DESC"],
176
- SCORE: ["SCORE_DESC"],
177
- TRENDING: ["TRENDING_DESC"],
178
- };
179
174
  const data = await anilistClient.query(SEASONAL_MEDIA_QUERY, {
180
175
  season,
181
176
  seasonYear: year,
182
177
  type: "ANIME",
183
178
  isAdult: args.isAdult ? undefined : false,
184
- sort: sortMap[args.sort] ?? sortMap.POPULARITY,
179
+ sort: BROWSE_SORT_MAP[args.sort] ?? BROWSE_SORT_MAP.POPULARITY,
185
180
  page: args.page,
186
181
  perPage: args.limit,
187
182
  }, { cache: "seasonal" });
@@ -4,7 +4,7 @@ import { SAVE_MEDIA_LIST_ENTRY_MUTATION, DELETE_MEDIA_LIST_ENTRY_MUTATION, TOGGL
4
4
  import { pushUndo, popUndo } from "../engine/undo.js";
5
5
  import { invalidateUserProfiles } from "../engine/profile-cache.js";
6
6
  import { UpdateProgressInputSchema, AddToListInputSchema, RateInputSchema, DeleteFromListInputSchema, FavouriteInputSchema, PostActivityInputSchema, UndoInputSchema, UnscoredInputSchema, BatchUpdateInputSchema, } from "../schemas.js";
7
- import { throwToolError, formatScore, detectScoreFormat, getTitle, getDefaultUsername, } from "../utils.js";
7
+ import { throwToolError, formatScore, getScoreFormat, getTitle, getDefaultUsername, } from "../utils.js";
8
8
  // === Auth Guard ===
9
9
  /** Guard against unauthenticated write attempts */
10
10
  function requireAuth() {
@@ -69,8 +69,9 @@ export function registerWriteTools(server) {
69
69
  status: args.status ?? "CURRENT",
70
70
  };
71
71
  const data = await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, variables, { cache: null });
72
- anilistClient.clearCache();
73
- invalidateUserProfiles(await getViewerName());
72
+ const viewerName = await getViewerName();
73
+ anilistClient.invalidateUser(viewerName);
74
+ invalidateUserProfiles(viewerName);
74
75
  const entry = data.SaveMediaListEntry;
75
76
  // Track for undo
76
77
  pushUndo({
@@ -122,13 +123,11 @@ export function registerWriteTools(server) {
122
123
  variables.scoreRaw = Math.round(args.score * 10);
123
124
  const [data, scoreFmt] = await Promise.all([
124
125
  anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, variables, { cache: null }),
125
- detectScoreFormat(async () => {
126
- const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
127
- return data.Viewer.mediaListOptions.scoreFormat;
128
- }),
126
+ getScoreFormat(),
129
127
  ]);
130
- anilistClient.clearCache();
131
- invalidateUserProfiles(await getViewerName());
128
+ const viewerName = await getViewerName();
129
+ anilistClient.invalidateUser(viewerName);
130
+ invalidateUserProfiles(viewerName);
132
131
  const entry = data.SaveMediaListEntry;
133
132
  // Track for undo
134
133
  pushUndo({
@@ -175,13 +174,11 @@ export function registerWriteTools(server) {
175
174
  const before = await snapshotByMediaId(args.mediaId);
176
175
  const [data, scoreFmt] = await Promise.all([
177
176
  anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, { mediaId: args.mediaId, scoreRaw: Math.round(args.score * 10) }, { cache: null }),
178
- detectScoreFormat(async () => {
179
- const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
180
- return data.Viewer.mediaListOptions.scoreFormat;
181
- }),
177
+ getScoreFormat(),
182
178
  ]);
183
- anilistClient.clearCache();
184
- invalidateUserProfiles(await getViewerName());
179
+ const viewerName = await getViewerName();
180
+ anilistClient.invalidateUser(viewerName);
181
+ invalidateUserProfiles(viewerName);
185
182
  const entry = data.SaveMediaListEntry;
186
183
  // Track for undo
187
184
  if (before) {
@@ -209,7 +206,7 @@ export function registerWriteTools(server) {
209
206
  server.addTool({
210
207
  name: "anilist_delete_from_list",
211
208
  description: "Remove an entry from your anime or manga list. " +
212
- "Requires the list entry ID (not the media ID) - use anilist_list to find it. " +
209
+ "Pass either a list entry ID or a media ID. " +
213
210
  "Requires ANILIST_TOKEN.",
214
211
  parameters: DeleteFromListInputSchema,
215
212
  annotations: {
@@ -222,13 +219,26 @@ export function registerWriteTools(server) {
222
219
  execute: async (args) => {
223
220
  try {
224
221
  requireAuth();
222
+ // Resolve mediaId to entryId if needed
223
+ let entryId = args.entryId;
224
+ if (!entryId && args.mediaId) {
225
+ const snapshot = await snapshotByMediaId(args.mediaId);
226
+ if (!snapshot) {
227
+ return `Media ${args.mediaId} is not on your list.`;
228
+ }
229
+ entryId = snapshot.id;
230
+ }
231
+ if (!entryId) {
232
+ return "Provide either an entryId or a mediaId.";
233
+ }
225
234
  // Snapshot before deletion
226
- const before = await snapshotByEntryId(args.entryId);
227
- const data = await anilistClient.query(DELETE_MEDIA_LIST_ENTRY_MUTATION, { id: args.entryId }, { cache: null });
228
- anilistClient.clearCache();
229
- invalidateUserProfiles(await getViewerName());
235
+ const before = await snapshotByEntryId(entryId);
236
+ const data = await anilistClient.query(DELETE_MEDIA_LIST_ENTRY_MUTATION, { id: entryId }, { cache: null });
237
+ const viewerName = await getViewerName();
238
+ anilistClient.invalidateUser(viewerName);
239
+ invalidateUserProfiles(viewerName);
230
240
  if (!data.DeleteMediaListEntry.deleted) {
231
- return `Entry ${args.entryId} was not found or already removed.`;
241
+ return `Entry ${entryId} was not found or already removed.`;
232
242
  }
233
243
  // Track for undo
234
244
  if (before) {
@@ -236,13 +246,13 @@ export function registerWriteTools(server) {
236
246
  operation: { type: "delete", before },
237
247
  toolName: "anilist_delete_from_list",
238
248
  timestamp: Date.now(),
239
- description: `Deleted entry ${args.entryId} (media ${before.mediaId})`,
249
+ description: `Deleted entry ${entryId} (media ${before.mediaId})`,
240
250
  });
241
251
  }
242
252
  const hint = before
243
253
  ? `\n(Deleted ${before.status} entry - say "undo" to restore)`
244
254
  : "";
245
- return `Entry ${args.entryId} deleted from your list.${hint}`;
255
+ return `Entry ${entryId} deleted from your list.${hint}`;
246
256
  }
247
257
  catch (error) {
248
258
  return throwToolError(error, "deleting from list");
@@ -278,15 +288,17 @@ export function registerWriteTools(server) {
278
288
  scoreRaw: op.before.score * 10,
279
289
  progress: op.before.progress,
280
290
  }, { cache: null });
281
- anilistClient.clearCache();
282
- invalidateUserProfiles(await getViewerName());
291
+ const viewerName = await getViewerName();
292
+ anilistClient.invalidateUser(viewerName);
293
+ invalidateUserProfiles(viewerName);
283
294
  return `Undone: restored media ${op.before.mediaId} to ${op.before.status}, progress ${op.before.progress}, score ${op.before.score}.`;
284
295
  }
285
296
  if (op.type === "create") {
286
297
  // Delete the newly created entry
287
298
  await anilistClient.query(DELETE_MEDIA_LIST_ENTRY_MUTATION, { id: op.entryId }, { cache: null });
288
- anilistClient.clearCache();
289
- invalidateUserProfiles(await getViewerName());
299
+ const viewerName = await getViewerName();
300
+ anilistClient.invalidateUser(viewerName);
301
+ invalidateUserProfiles(viewerName);
290
302
  return `Undone: removed media ${op.mediaId} from your list.`;
291
303
  }
292
304
  if (op.type === "delete") {
@@ -297,8 +309,9 @@ export function registerWriteTools(server) {
297
309
  scoreRaw: op.before.score * 10,
298
310
  progress: op.before.progress,
299
311
  }, { cache: null });
300
- anilistClient.clearCache();
301
- invalidateUserProfiles(await getViewerName());
312
+ const viewerName = await getViewerName();
313
+ anilistClient.invalidateUser(viewerName);
314
+ invalidateUserProfiles(viewerName);
302
315
  return `Undone: restored media ${op.before.mediaId} to ${op.before.status}, progress ${op.before.progress}.`;
303
316
  }
304
317
  if (op.type === "batch") {
@@ -318,8 +331,9 @@ export function registerWriteTools(server) {
318
331
  // Continue on individual failures
319
332
  }
320
333
  }
321
- anilistClient.clearCache();
322
- invalidateUserProfiles(await getViewerName());
334
+ const viewerName = await getViewerName();
335
+ anilistClient.invalidateUser(viewerName);
336
+ invalidateUserProfiles(viewerName);
323
337
  return `Undone: restored ${restored}/${op.entries.length} entries to their previous state.`;
324
338
  }
325
339
  return "Unknown undo operation type.";
@@ -364,7 +378,7 @@ export function registerWriteTools(server) {
364
378
  requireAuth();
365
379
  const variables = { [FAVOURITE_VAR_MAP[args.type]]: args.id };
366
380
  const data = await anilistClient.query(TOGGLE_FAVOURITE_MUTATION, variables, { cache: null });
367
- anilistClient.clearCache();
381
+ anilistClient.invalidateUser(await getViewerName());
368
382
  // Check if entity is now in favourites (added) or absent (removed)
369
383
  const field = FAVOURITE_FIELD_MAP[args.type];
370
384
  const isFavourited = data.ToggleFavourite[field].nodes.some((n) => n.id === args.id);
@@ -396,7 +410,7 @@ export function registerWriteTools(server) {
396
410
  try {
397
411
  requireAuth();
398
412
  const data = await anilistClient.query(SAVE_TEXT_ACTIVITY_MUTATION, { text: args.text }, { cache: null });
399
- anilistClient.clearCache();
413
+ anilistClient.invalidateUser(await getViewerName());
400
414
  const activity = data.SaveTextActivity;
401
415
  const dateStr = new Date(activity.createdAt * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
402
416
  return [
@@ -562,8 +576,9 @@ export function registerWriteTools(server) {
562
576
  failures++;
563
577
  }
564
578
  }
565
- anilistClient.clearCache();
566
- invalidateUserProfiles(await getViewerName());
579
+ const viewerName = await getViewerName();
580
+ anilistClient.invalidateUser(viewerName);
581
+ invalidateUserProfiles(viewerName);
567
582
  // Track batch for undo
568
583
  if (snapshots.length > 0) {
569
584
  pushUndo({
package/dist/utils.d.ts CHANGED
@@ -18,6 +18,10 @@ export declare function paginationFooter(page: number, limit: number, total: num
18
18
  export declare function formatMediaSummary(media: AniListMedia): string;
19
19
  /** Detect score format from env override or API fallback */
20
20
  export declare function detectScoreFormat(fetchFormat: () => Promise<ScoreFormat>): Promise<ScoreFormat>;
21
+ /** Fetch score format for a user (by username) or the authenticated viewer */
22
+ export declare function getScoreFormat(username?: string): Promise<ScoreFormat>;
23
+ /** Sort direction map for browse/seasonal tools */
24
+ export declare const BROWSE_SORT_MAP: Record<string, string[]>;
21
25
  /** Resolve season and year, defaulting to current if not provided */
22
26
  export declare function resolveSeasonYear(season?: string, year?: number): {
23
27
  season: string;
package/dist/utils.js CHANGED
@@ -1,5 +1,7 @@
1
1
  /** Formatting and resolution helpers. */
2
2
  import { UserError } from "fastmcp";
3
+ import { anilistClient } from "./api/client.js";
4
+ import { USER_STATS_QUERY, VIEWER_QUERY } from "./api/queries.js";
3
5
  /** Best available title, respecting ANILIST_TITLE_LANGUAGE preference */
4
6
  export function getTitle(title) {
5
7
  const pref = process.env.ANILIST_TITLE_LANGUAGE?.toLowerCase();
@@ -136,6 +138,23 @@ export async function detectScoreFormat(fetchFormat) {
136
138
  return "POINT_10";
137
139
  }
138
140
  }
141
+ /** Fetch score format for a user (by username) or the authenticated viewer */
142
+ export async function getScoreFormat(username) {
143
+ return detectScoreFormat(async () => {
144
+ if (username) {
145
+ const data = await anilistClient.query(USER_STATS_QUERY, { name: username }, { cache: "stats" });
146
+ return data.User.mediaListOptions.scoreFormat;
147
+ }
148
+ const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
149
+ return data.Viewer.mediaListOptions.scoreFormat;
150
+ });
151
+ }
152
+ /** Sort direction map for browse/seasonal tools */
153
+ export const BROWSE_SORT_MAP = {
154
+ SCORE: ["SCORE_DESC"],
155
+ POPULARITY: ["POPULARITY_DESC"],
156
+ TRENDING: ["TRENDING_DESC"],
157
+ };
139
158
  /** Resolve season and year, defaulting to current if not provided */
140
159
  export function resolveSeasonYear(season, year) {
141
160
  const now = new Date();
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ani-mcp",
4
- "version": "0.8.0",
4
+ "version": "0.8.2",
5
5
  "display_name": "AniList MCP",
6
6
  "description": "A smart MCP server for AniList that gets your anime/manga taste - not just API calls.",
7
7
  "author": {
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.8.0",
4
+ "version": "0.8.2",
5
5
  "description": "A smart [MCP](https://modelcontextprotocol.io) server for [AniList](https://anilist.co) that gets your anime/manga taste - not just API calls.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/gavxm/ani-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.8.0",
9
+ "version": "0.8.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ani-mcp",
14
- "version": "0.8.0",
14
+ "version": "0.8.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },