ani-mcp 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,6 +117,17 @@ Works with any MCP-compatible client.
117
117
  | `anilist_favourite` | Toggle favourite on anime, manga, character, staff, or studio |
118
118
  | `anilist_activity` | Post a text activity to your feed |
119
119
 
120
+ ### Analytics
121
+
122
+ | Tool | Description |
123
+ | --- | --- |
124
+ | `anilist_calibration` | Per-genre scoring bias vs community consensus |
125
+ | `anilist_drops` | Drop pattern analysis - genre/tag clusters and median drop point |
126
+ | `anilist_evolution` | How your taste shifted across 2-year time windows |
127
+ | `anilist_completionist` | Franchise completion tracking via relation graph |
128
+ | `anilist_seasonal_stats` | Per-season pick/finish/drop rates |
129
+ | `anilist_pace` | Estimated completion date for currently watching titles |
130
+
120
131
  ### Write (requires `ANILIST_TOKEN`)
121
132
 
122
133
  | Tool | Description |
@@ -125,6 +136,9 @@ Works with any MCP-compatible client.
125
136
  | `anilist_add_to_list` | Add a title to your list with a status |
126
137
  | `anilist_rate` | Score a title (0-10) |
127
138
  | `anilist_delete_from_list` | Remove an entry from your list |
139
+ | `anilist_undo` | Undo the last write operation |
140
+ | `anilist_unscored` | List completed but unscored titles for batch scoring |
141
+ | `anilist_batch_update` | Bulk filter + action on list entries (dry-run default) |
128
142
 
129
143
  ## Resources
130
144
 
@@ -28,6 +28,8 @@ export declare const AIRING_SCHEDULE_QUERY = "\n query AiringSchedule($id: Int,
28
28
  export declare const CHARACTER_SEARCH_QUERY = "\n query CharacterSearch($search: String!, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n characters(search: $search, sort: FAVOURITES_DESC) {\n id\n name { full native alternative }\n image { medium }\n favourites\n siteUrl\n media(sort: POPULARITY_DESC, perPage: 5) {\n edges {\n characterRole\n node {\n id\n title { romaji english }\n format\n type\n siteUrl\n }\n voiceActors(language: JAPANESE) {\n id\n name { full }\n siteUrl\n }\n }\n }\n }\n }\n }\n";
29
29
  /** Create or update a list entry */
30
30
  export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaListEntry(\n $mediaId: Int\n $status: MediaListStatus\n $scoreRaw: Int\n $progress: Int\n $notes: String\n $private: Boolean\n ) {\n SaveMediaListEntry(\n mediaId: $mediaId\n status: $status\n scoreRaw: $scoreRaw\n progress: $progress\n notes: $notes\n private: $private\n ) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n }\n }\n";
31
+ /** Fetch a single list entry for snapshotting before mutations */
32
+ export declare const MEDIA_LIST_ENTRY_QUERY = "\n query MediaListEntry($id: Int, $mediaId: Int, $userName: String) {\n MediaList(id: $id, mediaId: $mediaId, userName: $userName) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n notes\n private\n }\n }\n";
31
33
  /** Remove a list entry */
32
34
  export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
33
35
  /** User's anime/manga list, grouped by status. Omit $status to get all lists. */
@@ -394,6 +394,20 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
394
394
  }
395
395
  }
396
396
  `;
397
+ /** Fetch a single list entry for snapshotting before mutations */
398
+ export const MEDIA_LIST_ENTRY_QUERY = `
399
+ query MediaListEntry($id: Int, $mediaId: Int, $userName: String) {
400
+ MediaList(id: $id, mediaId: $mediaId, userName: $userName) {
401
+ id
402
+ mediaId
403
+ status
404
+ score(format: POINT_10)
405
+ progress
406
+ notes
407
+ private
408
+ }
409
+ }
410
+ `;
397
411
  /** Remove a list entry */
398
412
  export const DELETE_MEDIA_LIST_ENTRY_MUTATION = `
399
413
  mutation DeleteMediaListEntry($id: Int!) {
@@ -0,0 +1,13 @@
1
+ /** Cached taste profiles with hash-based invalidation. */
2
+ import type { TasteProfile } from "./taste.js";
3
+ import type { AniListMediaListEntry } from "../types.js";
4
+ /** Stable hash of entry IDs and scores to detect list changes */
5
+ export declare function computeListHash(entries: AniListMediaListEntry[]): string;
6
+ /** Get a cached profile if the list hash matches */
7
+ export declare function getCachedProfile(key: string, listHash: string): TasteProfile | undefined;
8
+ /** Store a profile in the cache */
9
+ export declare function setCachedProfile(key: string, profile: TasteProfile, listHash: string): void;
10
+ /** Invalidate all profiles for a username */
11
+ export declare function invalidateUserProfiles(username: string): void;
12
+ /** Clear all cached profiles */
13
+ export declare function clearProfileCache(): void;
@@ -0,0 +1,36 @@
1
+ /** Cached taste profiles with hash-based invalidation. */
2
+ import { LRUCache } from "lru-cache";
3
+ // 30-minute TTL, max 20 profiles
4
+ const cache = new LRUCache({
5
+ max: 20,
6
+ ttl: 30 * 60 * 1000,
7
+ });
8
+ /** Stable hash of entry IDs and scores to detect list changes */
9
+ export function computeListHash(entries) {
10
+ const pairs = entries.map((e) => `${e.id}:${e.score}`).sort();
11
+ return pairs.join(",");
12
+ }
13
+ /** Get a cached profile if the list hash matches */
14
+ export function getCachedProfile(key, listHash) {
15
+ const cached = cache.get(key);
16
+ if (cached && cached.listHash === listHash)
17
+ return cached.profile;
18
+ return undefined;
19
+ }
20
+ /** Store a profile in the cache */
21
+ export function setCachedProfile(key, profile, listHash) {
22
+ cache.set(key, { profile, listHash });
23
+ }
24
+ /** Invalidate all profiles for a username */
25
+ export function invalidateUserProfiles(username) {
26
+ const lower = username.toLowerCase();
27
+ for (const key of cache.keys()) {
28
+ if (key.toLowerCase().startsWith(`${lower}::`)) {
29
+ cache.delete(key);
30
+ }
31
+ }
32
+ }
33
+ /** Clear all cached profiles */
34
+ export function clearProfileCache() {
35
+ cache.clear();
36
+ }
@@ -0,0 +1,42 @@
1
+ /** Session-scoped undo stack for write operations. */
2
+ export interface EntrySnapshot {
3
+ id: number;
4
+ mediaId: number;
5
+ status: string;
6
+ score: number;
7
+ progress: number;
8
+ notes: string | null;
9
+ private: boolean;
10
+ }
11
+ export type UndoOperation = {
12
+ type: "update";
13
+ before: EntrySnapshot;
14
+ } | {
15
+ type: "create";
16
+ entryId: number;
17
+ mediaId: number;
18
+ } | {
19
+ type: "delete";
20
+ before: EntrySnapshot;
21
+ } | {
22
+ type: "batch";
23
+ entries: Array<{
24
+ before: EntrySnapshot;
25
+ }>;
26
+ };
27
+ export interface UndoRecord {
28
+ operation: UndoOperation;
29
+ toolName: string;
30
+ timestamp: number;
31
+ description: string;
32
+ }
33
+ /** Push a record onto the undo stack, trimming oldest if full */
34
+ export declare function pushUndo(record: UndoRecord): void;
35
+ /** Pop the most recent undo record */
36
+ export declare function popUndo(): UndoRecord | undefined;
37
+ /** Inspect the most recent record without removing */
38
+ export declare function peekUndo(): UndoRecord | undefined;
39
+ /** Current stack depth */
40
+ export declare function undoStackSize(): number;
41
+ /** Clear all undo records */
42
+ export declare function clearUndoStack(): void;
@@ -0,0 +1,26 @@
1
+ /** Session-scoped undo stack for write operations. */
2
+ // === Stack ===
3
+ const MAX_UNDO = 20;
4
+ const stack = [];
5
+ /** Push a record onto the undo stack, trimming oldest if full */
6
+ export function pushUndo(record) {
7
+ stack.push(record);
8
+ while (stack.length > MAX_UNDO)
9
+ stack.shift();
10
+ }
11
+ /** Pop the most recent undo record */
12
+ export function popUndo() {
13
+ return stack.pop();
14
+ }
15
+ /** Inspect the most recent record without removing */
16
+ export function peekUndo() {
17
+ return stack.length > 0 ? stack[stack.length - 1] : undefined;
18
+ }
19
+ /** Current stack depth */
20
+ export function undoStackSize() {
21
+ return stack.length;
22
+ }
23
+ /** Clear all undo records */
24
+ export function clearUndoStack() {
25
+ stack.length = 0;
26
+ }
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.7.0",
24
+ version: "0.8.0",
25
25
  });
26
26
  registerSearchTools(server);
27
27
  registerListTools(server);
package/dist/schemas.d.ts CHANGED
@@ -92,6 +92,7 @@ export declare const PickInputSchema: z.ZodObject<{
92
92
  year: z.ZodOptional<z.ZodNumber>;
93
93
  mood: z.ZodOptional<z.ZodString>;
94
94
  maxEpisodes: z.ZodOptional<z.ZodNumber>;
95
+ exclude: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
95
96
  limit: z.ZodDefault<z.ZodNumber>;
96
97
  }, z.core.$strip>;
97
98
  export type PickInput = z.infer<typeof PickInputSchema>;
@@ -423,3 +424,50 @@ export declare const PaceInputSchema: z.ZodObject<{
423
424
  }>>;
424
425
  }, z.core.$strip>;
425
426
  export type PaceInput = z.infer<typeof PaceInputSchema>;
427
+ /** Input for undoing the last write operation */
428
+ export declare const UndoInputSchema: z.ZodObject<{}, z.core.$strip>;
429
+ export type UndoInput = z.infer<typeof UndoInputSchema>;
430
+ /** Input for listing unscored completed titles */
431
+ export declare const UnscoredInputSchema: z.ZodObject<{
432
+ username: z.ZodOptional<z.ZodString>;
433
+ type: z.ZodDefault<z.ZodEnum<{
434
+ ANIME: "ANIME";
435
+ MANGA: "MANGA";
436
+ }>>;
437
+ limit: z.ZodDefault<z.ZodNumber>;
438
+ }, z.core.$strip>;
439
+ export type UnscoredInput = z.infer<typeof UnscoredInputSchema>;
440
+ /** Input for batch-updating multiple list entries */
441
+ export declare const BatchUpdateInputSchema: z.ZodObject<{
442
+ username: z.ZodOptional<z.ZodString>;
443
+ type: z.ZodDefault<z.ZodEnum<{
444
+ ANIME: "ANIME";
445
+ MANGA: "MANGA";
446
+ }>>;
447
+ filter: z.ZodObject<{
448
+ status: z.ZodOptional<z.ZodEnum<{
449
+ CURRENT: "CURRENT";
450
+ COMPLETED: "COMPLETED";
451
+ PLANNING: "PLANNING";
452
+ DROPPED: "DROPPED";
453
+ PAUSED: "PAUSED";
454
+ }>>;
455
+ scoreBelow: z.ZodOptional<z.ZodNumber>;
456
+ scoreAbove: z.ZodOptional<z.ZodNumber>;
457
+ unscored: z.ZodOptional<z.ZodBoolean>;
458
+ }, z.core.$strip>;
459
+ action: z.ZodObject<{
460
+ setStatus: z.ZodOptional<z.ZodEnum<{
461
+ CURRENT: "CURRENT";
462
+ COMPLETED: "COMPLETED";
463
+ PLANNING: "PLANNING";
464
+ DROPPED: "DROPPED";
465
+ PAUSED: "PAUSED";
466
+ REPEATING: "REPEATING";
467
+ }>>;
468
+ setScore: z.ZodOptional<z.ZodNumber>;
469
+ }, z.core.$strip>;
470
+ dryRun: z.ZodDefault<z.ZodBoolean>;
471
+ limit: z.ZodDefault<z.ZodNumber>;
472
+ }, z.core.$strip>;
473
+ export type BatchUpdateInput = z.infer<typeof BatchUpdateInputSchema>;
package/dist/schemas.js CHANGED
@@ -166,6 +166,11 @@ export const PickInputSchema = z.object({
166
166
  .positive()
167
167
  .optional()
168
168
  .describe("Filter out series longer than this episode count"),
169
+ exclude: z
170
+ .array(z.number().int().positive())
171
+ .max(50)
172
+ .optional()
173
+ .describe("Media IDs to exclude from results (e.g. from previous recommendations)"),
169
174
  limit: z
170
175
  .number()
171
176
  .int()
@@ -704,3 +709,85 @@ export const PaceInputSchema = z.object({
704
709
  .default("ANIME")
705
710
  .describe("Estimate pace for anime or manga"),
706
711
  });
712
+ // === 0.8.0 Persistent Intelligence ===
713
+ /** Input for undoing the last write operation */
714
+ export const UndoInputSchema = z.object({});
715
+ /** Input for listing unscored completed titles */
716
+ export const UnscoredInputSchema = z.object({
717
+ username: usernameSchema
718
+ .optional()
719
+ .describe("AniList username. Falls back to configured default if not provided."),
720
+ type: z
721
+ .enum(["ANIME", "MANGA"])
722
+ .default("ANIME")
723
+ .describe("Check anime or manga list for unscored titles"),
724
+ limit: z
725
+ .number()
726
+ .int()
727
+ .min(1)
728
+ .max(50)
729
+ .default(20)
730
+ .describe("Number of unscored titles to return (default 20, max 50)"),
731
+ });
732
+ /** Input for batch-updating multiple list entries */
733
+ export const BatchUpdateInputSchema = z.object({
734
+ username: usernameSchema
735
+ .optional()
736
+ .describe("AniList username. Falls back to configured default if not provided."),
737
+ type: z
738
+ .enum(["ANIME", "MANGA"])
739
+ .default("ANIME")
740
+ .describe("Update anime or manga entries"),
741
+ filter: z.object({
742
+ status: z
743
+ .enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED"])
744
+ .optional()
745
+ .describe("Only match entries with this status"),
746
+ scoreBelow: z
747
+ .number()
748
+ .min(0)
749
+ .max(10)
750
+ .optional()
751
+ .describe("Only match entries scored below this value"),
752
+ scoreAbove: z
753
+ .number()
754
+ .min(0)
755
+ .max(10)
756
+ .optional()
757
+ .describe("Only match entries scored above this value"),
758
+ unscored: z
759
+ .boolean()
760
+ .optional()
761
+ .describe("Only match entries with no score"),
762
+ }),
763
+ action: z.object({
764
+ setStatus: z
765
+ .enum([
766
+ "CURRENT",
767
+ "PLANNING",
768
+ "COMPLETED",
769
+ "DROPPED",
770
+ "PAUSED",
771
+ "REPEATING",
772
+ ])
773
+ .optional()
774
+ .describe("Change status to this value"),
775
+ setScore: z
776
+ .number()
777
+ .min(0)
778
+ .max(10)
779
+ .optional()
780
+ .describe("Set score to this value (0 removes score)"),
781
+ }),
782
+ dryRun: z
783
+ .boolean()
784
+ .default(true)
785
+ .describe("Preview changes without applying them. Set to false to execute."),
786
+ limit: z
787
+ .number()
788
+ .int()
789
+ .min(1)
790
+ .max(100)
791
+ .default(50)
792
+ .describe("Max entries to update in one call (default 50, max 100)"),
793
+ });
@@ -9,6 +9,7 @@ import { parseMood, hasMoodMatch, seasonalMoodSuggestions, } from "../engine/moo
9
9
  import { computeCompatibility, computeGenreDivergences, findCrossRecs, } from "../engine/compare.js";
10
10
  import { rankSimilar } from "../engine/similar.js";
11
11
  import { buildWatchOrder } from "../engine/franchise.js";
12
+ import { computeListHash, getCachedProfile, setCachedProfile, } from "../engine/profile-cache.js";
12
13
  // User scores are normalized to 1-10 via score(format: POINT_10) in the list query.
13
14
  // Community meanScore is 0-100. Multiply user score by 10 to compare on the same scale.
14
15
  const USER_SCORE_SCALE = 10;
@@ -29,18 +30,29 @@ async function discoverByTaste(profile, type, completedIds) {
29
30
  }, { cache: "search" });
30
31
  return data.Page.media.filter((m) => !completedIds.has(m.id));
31
32
  }
32
- /** Build a taste profile for a username, optionally merging anime and manga */
33
+ /** Build a taste profile for a username, with LRU caching */
33
34
  async function profileForUser(username, type) {
35
+ let entries;
34
36
  if (type === "BOTH") {
35
37
  const [anime, manga] = await Promise.all([
36
38
  anilistClient.fetchList(username, "ANIME", "COMPLETED"),
37
39
  anilistClient.fetchList(username, "MANGA", "COMPLETED"),
38
40
  ]);
39
- const all = [...anime, ...manga];
40
- return { profile: buildTasteProfile(all), entries: all };
41
+ entries = [...anime, ...manga];
41
42
  }
42
- const entries = await anilistClient.fetchList(username, type, "COMPLETED");
43
- return { profile: buildTasteProfile(entries), entries };
43
+ else {
44
+ entries = await anilistClient.fetchList(username, type, "COMPLETED");
45
+ }
46
+ // Check profile cache
47
+ const cacheKey = `${username}::${type}`;
48
+ const hash = computeListHash(entries);
49
+ const cached = getCachedProfile(cacheKey, hash);
50
+ if (cached)
51
+ return { profile: cached, entries };
52
+ // Rebuild and cache
53
+ const profile = buildTasteProfile(entries);
54
+ setCachedProfile(cacheKey, profile, hash);
55
+ return { profile, entries };
44
56
  }
45
57
  // === Tool Registration ===
46
58
  /** Register smart tools on the MCP server */
@@ -195,6 +207,11 @@ export function registerRecommendTools(server) {
195
207
  if (maxEps) {
196
208
  candidates = candidates.filter((m) => !m.episodes || m.episodes <= maxEps);
197
209
  }
210
+ // Exclude previously shown IDs
211
+ if (args.exclude?.length) {
212
+ const excludeSet = new Set(args.exclude);
213
+ candidates = candidates.filter((m) => !excludeSet.has(m.id));
214
+ }
198
215
  if (candidates.length === 0) {
199
216
  return fromDiscovery
200
217
  ? `Could not find titles matching ${username}'s taste. Try a different mood or type.`
@@ -1,4 +1,4 @@
1
- /** Write tools: list mutations, favourites, and activity posting. */
1
+ /** Write tools: list mutations, favourites, activity, undo, batch, and unscored. */
2
2
  import type { FastMCP } from "fastmcp";
3
3
  /** Register list mutation tools */
4
4
  export declare function registerWriteTools(server: FastMCP): void;
@@ -1,8 +1,10 @@
1
- /** Write tools: list mutations, favourites, and activity posting. */
1
+ /** Write tools: list mutations, favourites, activity, undo, batch, and unscored. */
2
2
  import { anilistClient } from "../api/client.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
- import { throwToolError, formatScore, detectScoreFormat } from "../utils.js";
3
+ import { SAVE_MEDIA_LIST_ENTRY_MUTATION, DELETE_MEDIA_LIST_ENTRY_MUTATION, TOGGLE_FAVOURITE_MUTATION, SAVE_TEXT_ACTIVITY_MUTATION, VIEWER_QUERY, MEDIA_LIST_ENTRY_QUERY, } from "../api/queries.js";
4
+ import { pushUndo, popUndo } from "../engine/undo.js";
5
+ import { invalidateUserProfiles } from "../engine/profile-cache.js";
6
+ import { UpdateProgressInputSchema, AddToListInputSchema, RateInputSchema, DeleteFromListInputSchema, FavouriteInputSchema, PostActivityInputSchema, UndoInputSchema, UnscoredInputSchema, BatchUpdateInputSchema, } from "../schemas.js";
7
+ import { throwToolError, formatScore, detectScoreFormat, getTitle, getDefaultUsername, } from "../utils.js";
6
8
  // === Auth Guard ===
7
9
  /** Guard against unauthenticated write attempts */
8
10
  function requireAuth() {
@@ -10,6 +12,34 @@ function requireAuth() {
10
12
  throw new Error("ANILIST_TOKEN is not set. Write operations require an authenticated AniList account.");
11
13
  }
12
14
  }
15
+ // === Undo Helpers ===
16
+ /** Get the authenticated user's username */
17
+ async function getViewerName() {
18
+ const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
19
+ return data.Viewer.name;
20
+ }
21
+ /** Snapshot a list entry before mutation */
22
+ async function snapshotByMediaId(mediaId) {
23
+ const userName = await getViewerName();
24
+ const data = await anilistClient.query(MEDIA_LIST_ENTRY_QUERY, { mediaId, userName }, { cache: null });
25
+ return data.MediaList ?? null;
26
+ }
27
+ /** Snapshot a list entry by its entry ID */
28
+ async function snapshotByEntryId(entryId) {
29
+ const data = await anilistClient.query(MEDIA_LIST_ENTRY_QUERY, { id: entryId }, { cache: null });
30
+ return data.MediaList ?? null;
31
+ }
32
+ /** Format an undo hint for output */
33
+ function undoHint(before) {
34
+ if (!before)
35
+ return '(New entry - say "undo" to remove)';
36
+ const parts = [before.status];
37
+ if (before.progress > 0)
38
+ parts.push(`progress ${before.progress}`);
39
+ if (before.score > 0)
40
+ parts.push(`score ${before.score}`);
41
+ return `(Previous: ${parts.join(", ")} - say "undo" to revert)`;
42
+ }
13
43
  // === Tool Registration ===
14
44
  /** Register list mutation tools */
15
45
  export function registerWriteTools(server) {
@@ -31,6 +61,8 @@ export function registerWriteTools(server) {
31
61
  execute: async (args) => {
32
62
  try {
33
63
  requireAuth();
64
+ // Snapshot before mutation
65
+ const before = await snapshotByMediaId(args.mediaId);
34
66
  const variables = {
35
67
  mediaId: args.mediaId,
36
68
  progress: args.progress,
@@ -38,12 +70,23 @@ export function registerWriteTools(server) {
38
70
  };
39
71
  const data = await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, variables, { cache: null });
40
72
  anilistClient.clearCache();
73
+ invalidateUserProfiles(await getViewerName());
41
74
  const entry = data.SaveMediaListEntry;
75
+ // Track for undo
76
+ pushUndo({
77
+ operation: before
78
+ ? { type: "update", before }
79
+ : { type: "create", entryId: entry.id, mediaId: args.mediaId },
80
+ toolName: "anilist_update_progress",
81
+ timestamp: Date.now(),
82
+ description: `Set progress to ${args.progress} on media ${args.mediaId}`,
83
+ });
42
84
  return [
43
85
  `Progress updated.`,
44
86
  `Status: ${entry.status}`,
45
87
  `Progress: ${entry.progress}`,
46
88
  `Entry ID: ${entry.id}`,
89
+ undoHint(before),
47
90
  ].join("\n");
48
91
  }
49
92
  catch (error) {
@@ -69,6 +112,8 @@ export function registerWriteTools(server) {
69
112
  execute: async (args) => {
70
113
  try {
71
114
  requireAuth();
115
+ // Snapshot before mutation
116
+ const before = await snapshotByMediaId(args.mediaId);
72
117
  const variables = {
73
118
  mediaId: args.mediaId,
74
119
  status: args.status,
@@ -83,7 +128,17 @@ export function registerWriteTools(server) {
83
128
  }),
84
129
  ]);
85
130
  anilistClient.clearCache();
131
+ invalidateUserProfiles(await getViewerName());
86
132
  const entry = data.SaveMediaListEntry;
133
+ // Track for undo
134
+ pushUndo({
135
+ operation: before
136
+ ? { type: "update", before }
137
+ : { type: "create", entryId: entry.id, mediaId: args.mediaId },
138
+ toolName: "anilist_add_to_list",
139
+ timestamp: Date.now(),
140
+ description: `Set status to ${args.status} on media ${args.mediaId}`,
141
+ });
87
142
  const scoreStr = entry.score > 0
88
143
  ? ` | Score: ${formatScore(entry.score, scoreFmt)}`
89
144
  : "";
@@ -91,6 +146,7 @@ export function registerWriteTools(server) {
91
146
  `Added to list.`,
92
147
  `Status: ${entry.status}${scoreStr}`,
93
148
  `Entry ID: ${entry.id}`,
149
+ undoHint(before),
94
150
  ].join("\n");
95
151
  }
96
152
  catch (error) {
@@ -115,6 +171,8 @@ export function registerWriteTools(server) {
115
171
  execute: async (args) => {
116
172
  try {
117
173
  requireAuth();
174
+ // Snapshot before mutation
175
+ const before = await snapshotByMediaId(args.mediaId);
118
176
  const [data, scoreFmt] = await Promise.all([
119
177
  anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, { mediaId: args.mediaId, scoreRaw: Math.round(args.score * 10) }, { cache: null }),
120
178
  detectScoreFormat(async () => {
@@ -123,11 +181,24 @@ export function registerWriteTools(server) {
123
181
  }),
124
182
  ]);
125
183
  anilistClient.clearCache();
184
+ invalidateUserProfiles(await getViewerName());
126
185
  const entry = data.SaveMediaListEntry;
186
+ // Track for undo
187
+ if (before) {
188
+ pushUndo({
189
+ operation: { type: "update", before },
190
+ toolName: "anilist_rate",
191
+ timestamp: Date.now(),
192
+ description: `Set score to ${args.score} on media ${args.mediaId}`,
193
+ });
194
+ }
127
195
  const scoreDisplay = args.score === 0
128
196
  ? "Score removed."
129
197
  : `Score set to ${formatScore(entry.score, scoreFmt)}.`;
130
- return [scoreDisplay, `Entry ID: ${entry.id}`].join("\n");
198
+ const lines = [scoreDisplay, `Entry ID: ${entry.id}`];
199
+ if (before)
200
+ lines.push(undoHint(before));
201
+ return lines.join("\n");
131
202
  }
132
203
  catch (error) {
133
204
  return throwToolError(error, "rating");
@@ -151,17 +222,113 @@ export function registerWriteTools(server) {
151
222
  execute: async (args) => {
152
223
  try {
153
224
  requireAuth();
225
+ // Snapshot before deletion
226
+ const before = await snapshotByEntryId(args.entryId);
154
227
  const data = await anilistClient.query(DELETE_MEDIA_LIST_ENTRY_MUTATION, { id: args.entryId }, { cache: null });
155
228
  anilistClient.clearCache();
156
- return data.DeleteMediaListEntry.deleted
157
- ? `Entry ${args.entryId} deleted from your list.`
158
- : `Entry ${args.entryId} was not found or already removed.`;
229
+ invalidateUserProfiles(await getViewerName());
230
+ if (!data.DeleteMediaListEntry.deleted) {
231
+ return `Entry ${args.entryId} was not found or already removed.`;
232
+ }
233
+ // Track for undo
234
+ if (before) {
235
+ pushUndo({
236
+ operation: { type: "delete", before },
237
+ toolName: "anilist_delete_from_list",
238
+ timestamp: Date.now(),
239
+ description: `Deleted entry ${args.entryId} (media ${before.mediaId})`,
240
+ });
241
+ }
242
+ const hint = before
243
+ ? `\n(Deleted ${before.status} entry - say "undo" to restore)`
244
+ : "";
245
+ return `Entry ${args.entryId} deleted from your list.${hint}`;
159
246
  }
160
247
  catch (error) {
161
248
  return throwToolError(error, "deleting from list");
162
249
  }
163
250
  },
164
251
  });
252
+ // === Undo ===
253
+ server.addTool({
254
+ name: "anilist_undo",
255
+ description: "Undo the last write operation (update progress, add to list, rate, delete, or batch update). " +
256
+ "Restores the previous state of the affected list entry. " +
257
+ "Requires ANILIST_TOKEN.",
258
+ parameters: UndoInputSchema,
259
+ annotations: {
260
+ title: "Undo",
261
+ readOnlyHint: false,
262
+ destructiveHint: true,
263
+ idempotentHint: false,
264
+ openWorldHint: true,
265
+ },
266
+ execute: async () => {
267
+ try {
268
+ requireAuth();
269
+ const record = popUndo();
270
+ if (!record)
271
+ return "Nothing to undo.";
272
+ const op = record.operation;
273
+ if (op.type === "update") {
274
+ // Restore previous entry state
275
+ await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, {
276
+ mediaId: op.before.mediaId,
277
+ status: op.before.status,
278
+ scoreRaw: op.before.score * 10,
279
+ progress: op.before.progress,
280
+ }, { cache: null });
281
+ anilistClient.clearCache();
282
+ invalidateUserProfiles(await getViewerName());
283
+ return `Undone: restored media ${op.before.mediaId} to ${op.before.status}, progress ${op.before.progress}, score ${op.before.score}.`;
284
+ }
285
+ if (op.type === "create") {
286
+ // Delete the newly created entry
287
+ await anilistClient.query(DELETE_MEDIA_LIST_ENTRY_MUTATION, { id: op.entryId }, { cache: null });
288
+ anilistClient.clearCache();
289
+ invalidateUserProfiles(await getViewerName());
290
+ return `Undone: removed media ${op.mediaId} from your list.`;
291
+ }
292
+ if (op.type === "delete") {
293
+ // Re-create the deleted entry
294
+ await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, {
295
+ mediaId: op.before.mediaId,
296
+ status: op.before.status,
297
+ scoreRaw: op.before.score * 10,
298
+ progress: op.before.progress,
299
+ }, { cache: null });
300
+ anilistClient.clearCache();
301
+ invalidateUserProfiles(await getViewerName());
302
+ return `Undone: restored media ${op.before.mediaId} to ${op.before.status}, progress ${op.before.progress}.`;
303
+ }
304
+ if (op.type === "batch") {
305
+ // Restore all entries in the batch
306
+ let restored = 0;
307
+ for (const item of op.entries) {
308
+ try {
309
+ await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, {
310
+ mediaId: item.before.mediaId,
311
+ status: item.before.status,
312
+ scoreRaw: item.before.score * 10,
313
+ progress: item.before.progress,
314
+ }, { cache: null });
315
+ restored++;
316
+ }
317
+ catch {
318
+ // Continue on individual failures
319
+ }
320
+ }
321
+ anilistClient.clearCache();
322
+ invalidateUserProfiles(await getViewerName());
323
+ return `Undone: restored ${restored}/${op.entries.length} entries to their previous state.`;
324
+ }
325
+ return "Unknown undo operation type.";
326
+ }
327
+ catch (error) {
328
+ return throwToolError(error, "undoing operation");
329
+ }
330
+ },
331
+ });
165
332
  // === Toggle Favourite ===
166
333
  // Map entity type to mutation variable name
167
334
  const FAVOURITE_VAR_MAP = {
@@ -244,4 +411,182 @@ export function registerWriteTools(server) {
244
411
  }
245
412
  },
246
413
  });
414
+ // === Unscored Listing ===
415
+ server.addTool({
416
+ name: "anilist_unscored",
417
+ description: "List completed anime or manga that haven't been scored yet. " +
418
+ "Use when the user wants to catch up on scoring, find unrated titles, " +
419
+ "or do a batch scoring session. Returns titles sorted by most recently completed.",
420
+ parameters: UnscoredInputSchema,
421
+ annotations: {
422
+ title: "Unscored Titles",
423
+ readOnlyHint: true,
424
+ destructiveHint: false,
425
+ idempotentHint: true,
426
+ openWorldHint: true,
427
+ },
428
+ execute: async (args) => {
429
+ try {
430
+ const username = getDefaultUsername(args.username);
431
+ const entries = await anilistClient.fetchList(username, args.type, "COMPLETED");
432
+ // Filter to unscored, sort by most recently completed
433
+ const unscored = entries
434
+ .filter((e) => e.score === 0)
435
+ .sort((a, b) => b.updatedAt - a.updatedAt)
436
+ .slice(0, args.limit);
437
+ if (unscored.length === 0) {
438
+ const total = entries.length;
439
+ return `All ${total} completed ${args.type.toLowerCase()} titles are scored.`;
440
+ }
441
+ const totalUnscored = entries.filter((e) => e.score === 0).length;
442
+ const lines = [
443
+ `# Unscored ${args.type.toLowerCase()} for ${username}`,
444
+ "",
445
+ `${totalUnscored} completed but unscored (showing ${unscored.length}).`,
446
+ "",
447
+ ];
448
+ for (const e of unscored) {
449
+ const title = getTitle(e.media.title);
450
+ const format = e.media.format ?? "?";
451
+ const community = e.media.meanScore != null
452
+ ? ` - Community: ${e.media.meanScore}`
453
+ : "";
454
+ const genres = e.media.genres.length > 0
455
+ ? ` Genres: ${e.media.genres.join(", ")}`
456
+ : "";
457
+ lines.push(`${title} (${format})${community}`);
458
+ if (genres)
459
+ lines.push(genres);
460
+ lines.push(` Media ID: ${e.media.id}`);
461
+ }
462
+ lines.push("", "Use anilist_rate with the media ID to score each title.");
463
+ return lines.join("\n");
464
+ }
465
+ catch (error) {
466
+ return throwToolError(error, "listing unscored titles");
467
+ }
468
+ },
469
+ });
470
+ // === Batch Update ===
471
+ server.addTool({
472
+ name: "anilist_batch_update",
473
+ description: "Apply a bulk action to multiple list entries matching a filter. " +
474
+ "Use when the user wants to move all low-scored titles to Dropped, " +
475
+ "add all planning titles to current, or bulk-change statuses. " +
476
+ "Defaults to dry-run mode (preview only). Requires ANILIST_TOKEN.",
477
+ parameters: BatchUpdateInputSchema,
478
+ annotations: {
479
+ title: "Batch Update",
480
+ readOnlyHint: false,
481
+ destructiveHint: true,
482
+ idempotentHint: false,
483
+ openWorldHint: true,
484
+ },
485
+ execute: async (args) => {
486
+ try {
487
+ requireAuth();
488
+ const username = getDefaultUsername(args.username);
489
+ // Fetch entries, optionally filtering by status
490
+ const entries = await anilistClient.fetchList(username, args.type, args.filter.status);
491
+ // Apply client-side filters
492
+ let matched = entries;
493
+ const { scoreBelow, scoreAbove } = args.filter;
494
+ if (scoreBelow !== undefined) {
495
+ matched = matched.filter((e) => e.score > 0 && e.score < scoreBelow);
496
+ }
497
+ if (scoreAbove !== undefined) {
498
+ matched = matched.filter((e) => e.score > 0 && e.score > scoreAbove);
499
+ }
500
+ if (args.filter.unscored) {
501
+ matched = matched.filter((e) => e.score === 0);
502
+ }
503
+ // Cap at limit
504
+ matched = matched.slice(0, args.limit);
505
+ if (matched.length === 0) {
506
+ return "No entries match the specified filter.";
507
+ }
508
+ // Build action description
509
+ const actionParts = [];
510
+ if (args.action.setStatus)
511
+ actionParts.push(`status -> ${args.action.setStatus}`);
512
+ if (args.action.setScore !== undefined)
513
+ actionParts.push(`score -> ${args.action.setScore}`);
514
+ const actionStr = actionParts.join(", ");
515
+ if (args.dryRun) {
516
+ // Preview mode
517
+ const lines = [
518
+ `# Batch Update Preview`,
519
+ "",
520
+ `Action: ${actionStr}`,
521
+ `Matched: ${matched.length} entries`,
522
+ "",
523
+ ];
524
+ for (const e of matched.slice(0, 20)) {
525
+ const title = getTitle(e.media.title);
526
+ const format = e.media.format ?? "?";
527
+ const score = e.score > 0 ? `, score ${e.score}` : "";
528
+ lines.push(` ${title} (${format}) - ${e.status}${score}`);
529
+ }
530
+ if (matched.length > 20) {
531
+ lines.push(` ... and ${matched.length - 20} more`);
532
+ }
533
+ lines.push("", "Run again with dryRun: false to apply.");
534
+ return lines.join("\n");
535
+ }
536
+ // Execute mutations
537
+ const snapshots = [];
538
+ let successes = 0;
539
+ let failures = 0;
540
+ for (const e of matched) {
541
+ try {
542
+ // Snapshot before mutation
543
+ const before = {
544
+ id: e.id,
545
+ mediaId: e.media.id,
546
+ status: e.status,
547
+ score: e.score,
548
+ progress: e.progress,
549
+ notes: e.notes,
550
+ private: false,
551
+ };
552
+ const vars = { mediaId: e.media.id };
553
+ if (args.action.setStatus)
554
+ vars.status = args.action.setStatus;
555
+ if (args.action.setScore !== undefined)
556
+ vars.scoreRaw = Math.round(args.action.setScore * 10);
557
+ await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, vars, { cache: null });
558
+ snapshots.push({ before });
559
+ successes++;
560
+ }
561
+ catch {
562
+ failures++;
563
+ }
564
+ }
565
+ anilistClient.clearCache();
566
+ invalidateUserProfiles(await getViewerName());
567
+ // Track batch for undo
568
+ if (snapshots.length > 0) {
569
+ pushUndo({
570
+ operation: { type: "batch", entries: snapshots },
571
+ toolName: "anilist_batch_update",
572
+ timestamp: Date.now(),
573
+ description: `Batch: ${actionStr} on ${snapshots.length} entries`,
574
+ });
575
+ }
576
+ const lines = [
577
+ `# Batch Update Complete`,
578
+ "",
579
+ `Updated ${successes} of ${matched.length} entries (${actionStr}).`,
580
+ ];
581
+ if (failures > 0)
582
+ lines.push(`${failures} entries failed.`);
583
+ if (snapshots.length > 0)
584
+ lines.push(`Say "undo" to revert all ${snapshots.length} changes.`);
585
+ return lines.join("\n");
586
+ }
587
+ catch (error) {
588
+ return throwToolError(error, "batch updating entries");
589
+ }
590
+ },
591
+ });
247
592
  }
package/dist/types.d.ts CHANGED
@@ -282,6 +282,18 @@ export interface CharacterSearchResponse {
282
282
  }>;
283
283
  };
284
284
  }
285
+ /** Single list entry snapshot for undo support */
286
+ export interface MediaListEntryResponse {
287
+ MediaList: {
288
+ id: number;
289
+ mediaId: number;
290
+ status: string;
291
+ score: number;
292
+ progress: number;
293
+ notes: string | null;
294
+ private: boolean;
295
+ } | null;
296
+ }
285
297
  /** Response from saving a list entry */
286
298
  export interface SaveMediaListEntryResponse {
287
299
  SaveMediaListEntry: {
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ani-mcp",
4
- "version": "0.7.0",
4
+ "version": "0.8.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": {
@@ -164,6 +164,15 @@
164
164
  },
165
165
  {
166
166
  "name": "anilist_pace"
167
+ },
168
+ {
169
+ "name": "anilist_undo"
170
+ },
171
+ {
172
+ "name": "anilist_unscored"
173
+ },
174
+ {
175
+ "name": "anilist_batch_update"
167
176
  }
168
177
  ]
169
178
  }
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.7.0",
4
+ "version": "0.8.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.7.0",
9
+ "version": "0.8.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ani-mcp",
14
- "version": "0.7.0",
14
+ "version": "0.8.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },