ani-mcp 0.7.1 → 0.8.1
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/client.d.ts +4 -0
- package/dist/api/client.js +22 -1
- package/dist/api/queries.d.ts +2 -0
- package/dist/api/queries.js +14 -0
- package/dist/engine/compare.js +7 -4
- 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/resources.js +3 -6
- package/dist/schemas.d.ts +54 -3
- package/dist/schemas.js +122 -7
- package/dist/tools/discover.js +3 -8
- package/dist/tools/info.js +1 -1
- package/dist/tools/lists.js +3 -10
- package/dist/tools/recommend.js +55 -20
- package/dist/tools/search.js +2 -7
- package/dist/tools/write.d.ts +1 -1
- package/dist/tools/write.js +384 -24
- package/dist/types.d.ts +12 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +19 -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/client.d.ts
CHANGED
|
@@ -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 */
|
package/dist/api/client.js
CHANGED
|
@@ -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}::${
|
|
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);
|
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!) {
|
package/dist/engine/compare.js
CHANGED
|
@@ -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 =
|
|
41
|
-
const rank2 =
|
|
42
|
-
if (rank1 >= 0 && rank1 < 5 && (rank2
|
|
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
|
|
52
|
+
else if (rank2 >= 0 && rank2 < 5 && (rank1 === -1 || rank1 > 10)) {
|
|
50
53
|
divergences.push({
|
|
51
54
|
genre,
|
|
52
55
|
diff: 10,
|
|
@@ -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/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
|
|
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,
|
|
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
|
-
|
|
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
|
@@ -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>;
|
|
@@ -277,11 +278,13 @@ export declare const RateInputSchema: z.ZodObject<{
|
|
|
277
278
|
export type RateInput = z.infer<typeof RateInputSchema>;
|
|
278
279
|
/** Input for removing a title from the list */
|
|
279
280
|
export declare const DeleteFromListInputSchema: z.ZodObject<{
|
|
280
|
-
entryId: z.ZodNumber
|
|
281
|
+
entryId: z.ZodOptional<z.ZodNumber>;
|
|
282
|
+
mediaId: z.ZodOptional<z.ZodNumber>;
|
|
281
283
|
}, z.core.$strip>;
|
|
282
284
|
/** Input for scoring a title against a user's taste profile */
|
|
283
285
|
export declare const ExplainInputSchema: z.ZodObject<{
|
|
284
|
-
mediaId: z.ZodNumber
|
|
286
|
+
mediaId: z.ZodOptional<z.ZodNumber>;
|
|
287
|
+
title: z.ZodOptional<z.ZodString>;
|
|
285
288
|
username: z.ZodOptional<z.ZodString>;
|
|
286
289
|
type: z.ZodDefault<z.ZodEnum<{
|
|
287
290
|
ANIME: "ANIME";
|
|
@@ -293,7 +296,8 @@ export declare const ExplainInputSchema: z.ZodObject<{
|
|
|
293
296
|
export type ExplainInput = z.infer<typeof ExplainInputSchema>;
|
|
294
297
|
/** Input for finding titles similar to a specific anime or manga */
|
|
295
298
|
export declare const SimilarInputSchema: z.ZodObject<{
|
|
296
|
-
mediaId: z.ZodNumber
|
|
299
|
+
mediaId: z.ZodOptional<z.ZodNumber>;
|
|
300
|
+
title: z.ZodOptional<z.ZodString>;
|
|
297
301
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
298
302
|
}, z.core.$strip>;
|
|
299
303
|
export type SimilarInput = z.infer<typeof SimilarInputSchema>;
|
|
@@ -423,3 +427,50 @@ export declare const PaceInputSchema: z.ZodObject<{
|
|
|
423
427
|
}>>;
|
|
424
428
|
}, z.core.$strip>;
|
|
425
429
|
export type PaceInput = z.infer<typeof PaceInputSchema>;
|
|
430
|
+
/** Input for undoing the last write operation */
|
|
431
|
+
export declare const UndoInputSchema: z.ZodObject<{}, z.core.$strip>;
|
|
432
|
+
export type UndoInput = z.infer<typeof UndoInputSchema>;
|
|
433
|
+
/** Input for listing unscored completed titles */
|
|
434
|
+
export declare const UnscoredInputSchema: z.ZodObject<{
|
|
435
|
+
username: z.ZodOptional<z.ZodString>;
|
|
436
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
437
|
+
ANIME: "ANIME";
|
|
438
|
+
MANGA: "MANGA";
|
|
439
|
+
}>>;
|
|
440
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
441
|
+
}, z.core.$strip>;
|
|
442
|
+
export type UnscoredInput = z.infer<typeof UnscoredInputSchema>;
|
|
443
|
+
/** Input for batch-updating multiple list entries */
|
|
444
|
+
export declare const BatchUpdateInputSchema: z.ZodObject<{
|
|
445
|
+
username: z.ZodOptional<z.ZodString>;
|
|
446
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
447
|
+
ANIME: "ANIME";
|
|
448
|
+
MANGA: "MANGA";
|
|
449
|
+
}>>;
|
|
450
|
+
filter: z.ZodObject<{
|
|
451
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
452
|
+
CURRENT: "CURRENT";
|
|
453
|
+
COMPLETED: "COMPLETED";
|
|
454
|
+
PLANNING: "PLANNING";
|
|
455
|
+
DROPPED: "DROPPED";
|
|
456
|
+
PAUSED: "PAUSED";
|
|
457
|
+
}>>;
|
|
458
|
+
scoreBelow: z.ZodOptional<z.ZodNumber>;
|
|
459
|
+
scoreAbove: z.ZodOptional<z.ZodNumber>;
|
|
460
|
+
unscored: z.ZodOptional<z.ZodBoolean>;
|
|
461
|
+
}, z.core.$strip>;
|
|
462
|
+
action: z.ZodObject<{
|
|
463
|
+
setStatus: z.ZodOptional<z.ZodEnum<{
|
|
464
|
+
CURRENT: "CURRENT";
|
|
465
|
+
COMPLETED: "COMPLETED";
|
|
466
|
+
PLANNING: "PLANNING";
|
|
467
|
+
DROPPED: "DROPPED";
|
|
468
|
+
PAUSED: "PAUSED";
|
|
469
|
+
REPEATING: "REPEATING";
|
|
470
|
+
}>>;
|
|
471
|
+
setScore: z.ZodOptional<z.ZodNumber>;
|
|
472
|
+
}, z.core.$strip>;
|
|
473
|
+
dryRun: z.ZodDefault<z.ZodBoolean>;
|
|
474
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
475
|
+
}, z.core.$strip>;
|
|
476
|
+
export type BatchUpdateInput = z.infer<typeof BatchUpdateInputSchema>;
|
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
|
|
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
|
|
@@ -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()
|
|
@@ -466,21 +471,37 @@ export const RateInputSchema = z.object({
|
|
|
466
471
|
.describe("Score on a 0-10 scale. Use 0 to remove a score."),
|
|
467
472
|
});
|
|
468
473
|
/** Input for removing a title from the list */
|
|
469
|
-
export const DeleteFromListInputSchema = z
|
|
474
|
+
export const DeleteFromListInputSchema = z
|
|
475
|
+
.object({
|
|
470
476
|
entryId: z
|
|
471
477
|
.number()
|
|
472
478
|
.int()
|
|
473
479
|
.positive()
|
|
474
|
-
.
|
|
475
|
-
"
|
|
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.",
|
|
476
491
|
});
|
|
477
492
|
/** Input for scoring a title against a user's taste profile */
|
|
478
|
-
export const ExplainInputSchema = z
|
|
493
|
+
export const ExplainInputSchema = z
|
|
494
|
+
.object({
|
|
479
495
|
mediaId: z
|
|
480
496
|
.number()
|
|
481
497
|
.int()
|
|
482
498
|
.positive()
|
|
499
|
+
.optional()
|
|
483
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"),
|
|
484
505
|
username: usernameSchema
|
|
485
506
|
.optional()
|
|
486
507
|
.describe("AniList username. Falls back to configured default if not provided."),
|
|
@@ -492,14 +513,23 @@ export const ExplainInputSchema = z.object({
|
|
|
492
513
|
.string()
|
|
493
514
|
.optional()
|
|
494
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.",
|
|
495
519
|
});
|
|
496
520
|
/** Input for finding titles similar to a specific anime or manga */
|
|
497
|
-
export const SimilarInputSchema = z
|
|
521
|
+
export const SimilarInputSchema = z
|
|
522
|
+
.object({
|
|
498
523
|
mediaId: z
|
|
499
524
|
.number()
|
|
500
525
|
.int()
|
|
501
526
|
.positive()
|
|
527
|
+
.optional()
|
|
502
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"),
|
|
503
533
|
limit: z
|
|
504
534
|
.number()
|
|
505
535
|
.int()
|
|
@@ -507,6 +537,9 @@ export const SimilarInputSchema = z.object({
|
|
|
507
537
|
.max(25)
|
|
508
538
|
.default(10)
|
|
509
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.",
|
|
510
543
|
});
|
|
511
544
|
/** Input for searching staff/people by name */
|
|
512
545
|
export const StaffSearchInputSchema = z.object({
|
|
@@ -704,3 +737,85 @@ export const PaceInputSchema = z.object({
|
|
|
704
737
|
.default("ANIME")
|
|
705
738
|
.describe("Estimate pace for anime or manga"),
|
|
706
739
|
});
|
|
740
|
+
// === 0.8.0 Persistent Intelligence ===
|
|
741
|
+
/** Input for undoing the last write operation */
|
|
742
|
+
export const UndoInputSchema = z.object({});
|
|
743
|
+
/** Input for listing unscored completed titles */
|
|
744
|
+
export const UnscoredInputSchema = z.object({
|
|
745
|
+
username: usernameSchema
|
|
746
|
+
.optional()
|
|
747
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
748
|
+
type: z
|
|
749
|
+
.enum(["ANIME", "MANGA"])
|
|
750
|
+
.default("ANIME")
|
|
751
|
+
.describe("Check anime or manga list for unscored titles"),
|
|
752
|
+
limit: z
|
|
753
|
+
.number()
|
|
754
|
+
.int()
|
|
755
|
+
.min(1)
|
|
756
|
+
.max(50)
|
|
757
|
+
.default(20)
|
|
758
|
+
.describe("Number of unscored titles to return (default 20, max 50)"),
|
|
759
|
+
});
|
|
760
|
+
/** Input for batch-updating multiple list entries */
|
|
761
|
+
export const BatchUpdateInputSchema = z.object({
|
|
762
|
+
username: usernameSchema
|
|
763
|
+
.optional()
|
|
764
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
765
|
+
type: z
|
|
766
|
+
.enum(["ANIME", "MANGA"])
|
|
767
|
+
.default("ANIME")
|
|
768
|
+
.describe("Update anime or manga entries"),
|
|
769
|
+
filter: z.object({
|
|
770
|
+
status: z
|
|
771
|
+
.enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED"])
|
|
772
|
+
.optional()
|
|
773
|
+
.describe("Only match entries with this status"),
|
|
774
|
+
scoreBelow: z
|
|
775
|
+
.number()
|
|
776
|
+
.min(0)
|
|
777
|
+
.max(10)
|
|
778
|
+
.optional()
|
|
779
|
+
.describe("Only match entries scored below this value"),
|
|
780
|
+
scoreAbove: z
|
|
781
|
+
.number()
|
|
782
|
+
.min(0)
|
|
783
|
+
.max(10)
|
|
784
|
+
.optional()
|
|
785
|
+
.describe("Only match entries scored above this value"),
|
|
786
|
+
unscored: z
|
|
787
|
+
.boolean()
|
|
788
|
+
.optional()
|
|
789
|
+
.describe("Only match entries with no score"),
|
|
790
|
+
}),
|
|
791
|
+
action: z.object({
|
|
792
|
+
setStatus: z
|
|
793
|
+
.enum([
|
|
794
|
+
"CURRENT",
|
|
795
|
+
"PLANNING",
|
|
796
|
+
"COMPLETED",
|
|
797
|
+
"DROPPED",
|
|
798
|
+
"PAUSED",
|
|
799
|
+
"REPEATING",
|
|
800
|
+
])
|
|
801
|
+
.optional()
|
|
802
|
+
.describe("Change status to this value"),
|
|
803
|
+
setScore: z
|
|
804
|
+
.number()
|
|
805
|
+
.min(0)
|
|
806
|
+
.max(10)
|
|
807
|
+
.optional()
|
|
808
|
+
.describe("Set score to this value (0 removes score)"),
|
|
809
|
+
}),
|
|
810
|
+
dryRun: z
|
|
811
|
+
.boolean()
|
|
812
|
+
.default(true)
|
|
813
|
+
.describe("Preview changes without applying them. Set to false to execute."),
|
|
814
|
+
limit: z
|
|
815
|
+
.number()
|
|
816
|
+
.int()
|
|
817
|
+
.min(1)
|
|
818
|
+
.max(100)
|
|
819
|
+
.default(50)
|
|
820
|
+
.describe("Max entries to update in one call (default 50, max 100)"),
|
|
821
|
+
});
|
package/dist/tools/discover.js
CHANGED
|
@@ -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: "
|
|
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:
|
|
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,
|
package/dist/tools/info.js
CHANGED
|
@@ -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: "
|
|
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)}`,
|
package/dist/tools/lists.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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);
|