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 +14 -0
- package/dist/api/queries.d.ts +2 -0
- package/dist/api/queries.js +14 -0
- package/dist/engine/profile-cache.d.ts +13 -0
- package/dist/engine/profile-cache.js +36 -0
- package/dist/engine/undo.d.ts +42 -0
- package/dist/engine/undo.js +26 -0
- package/dist/index.js +1 -1
- package/dist/schemas.d.ts +48 -0
- package/dist/schemas.js +87 -0
- package/dist/tools/recommend.js +22 -5
- package/dist/tools/write.d.ts +1 -1
- package/dist/tools/write.js +353 -8
- package/dist/types.d.ts +12 -0
- package/manifest.json +10 -1
- package/package.json +1 -1
- package/server.json +2 -2
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
|
|
package/dist/api/queries.d.ts
CHANGED
|
@@ -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. */
|
package/dist/api/queries.js
CHANGED
|
@@ -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
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
|
+
});
|
package/dist/tools/recommend.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
40
|
-
return { profile: buildTasteProfile(all), entries: all };
|
|
41
|
+
entries = [...anime, ...manga];
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
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.`
|
package/dist/tools/write.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Write tools: list mutations, favourites, and
|
|
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;
|
package/dist/tools/write.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
/** Write tools: list mutations, favourites, and
|
|
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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.8.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ani-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.8.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|