ani-mcp 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +4 -0
- package/dist/api/client.js +22 -1
- package/dist/engine/compare.js +7 -4
- package/dist/index.js +1 -1
- package/dist/resources.js +3 -6
- package/dist/schemas.d.ts +6 -3
- package/dist/schemas.js +35 -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 +33 -15
- package/dist/tools/search.js +2 -7
- package/dist/tools/write.js +50 -35
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +19 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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/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,
|
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
|
@@ -278,11 +278,13 @@ export declare const RateInputSchema: z.ZodObject<{
|
|
|
278
278
|
export type RateInput = z.infer<typeof RateInputSchema>;
|
|
279
279
|
/** Input for removing a title from the list */
|
|
280
280
|
export declare const DeleteFromListInputSchema: z.ZodObject<{
|
|
281
|
-
entryId: z.ZodNumber
|
|
281
|
+
entryId: z.ZodOptional<z.ZodNumber>;
|
|
282
|
+
mediaId: z.ZodOptional<z.ZodNumber>;
|
|
282
283
|
}, z.core.$strip>;
|
|
283
284
|
/** Input for scoring a title against a user's taste profile */
|
|
284
285
|
export declare const ExplainInputSchema: z.ZodObject<{
|
|
285
|
-
mediaId: z.ZodNumber
|
|
286
|
+
mediaId: z.ZodOptional<z.ZodNumber>;
|
|
287
|
+
title: z.ZodOptional<z.ZodString>;
|
|
286
288
|
username: z.ZodOptional<z.ZodString>;
|
|
287
289
|
type: z.ZodDefault<z.ZodEnum<{
|
|
288
290
|
ANIME: "ANIME";
|
|
@@ -294,7 +296,8 @@ export declare const ExplainInputSchema: z.ZodObject<{
|
|
|
294
296
|
export type ExplainInput = z.infer<typeof ExplainInputSchema>;
|
|
295
297
|
/** Input for finding titles similar to a specific anime or manga */
|
|
296
298
|
export declare const SimilarInputSchema: z.ZodObject<{
|
|
297
|
-
mediaId: z.ZodNumber
|
|
299
|
+
mediaId: z.ZodOptional<z.ZodNumber>;
|
|
300
|
+
title: z.ZodOptional<z.ZodString>;
|
|
298
301
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
299
302
|
}, z.core.$strip>;
|
|
300
303
|
export type SimilarInput = z.infer<typeof SimilarInputSchema>;
|
package/dist/schemas.js
CHANGED
|
@@ -7,12 +7,12 @@ const pageParam = z
|
|
|
7
7
|
.min(1)
|
|
8
8
|
.default(1)
|
|
9
9
|
.describe("Page number for pagination (default 1)");
|
|
10
|
-
// AniList usernames: 2-20 chars, alphanumeric + underscores
|
|
10
|
+
// AniList usernames: 2-20 chars, alphanumeric + underscores + hyphens
|
|
11
11
|
const usernameSchema = z
|
|
12
12
|
.string()
|
|
13
13
|
.min(2)
|
|
14
14
|
.max(20)
|
|
15
|
-
.regex(/^[a-zA-Z0-9_]+$/, "Letters, numbers, and
|
|
15
|
+
.regex(/^[a-zA-Z0-9_-]+$/, "Letters, numbers, underscores, and hyphens only");
|
|
16
16
|
/** Input for searching anime or manga by title and filters */
|
|
17
17
|
export const SearchInputSchema = z.object({
|
|
18
18
|
query: z
|
|
@@ -471,21 +471,37 @@ export const RateInputSchema = z.object({
|
|
|
471
471
|
.describe("Score on a 0-10 scale. Use 0 to remove a score."),
|
|
472
472
|
});
|
|
473
473
|
/** Input for removing a title from the list */
|
|
474
|
-
export const DeleteFromListInputSchema = z
|
|
474
|
+
export const DeleteFromListInputSchema = z
|
|
475
|
+
.object({
|
|
475
476
|
entryId: z
|
|
476
477
|
.number()
|
|
477
478
|
.int()
|
|
478
479
|
.positive()
|
|
479
|
-
.
|
|
480
|
-
"
|
|
480
|
+
.optional()
|
|
481
|
+
.describe("List entry ID to delete (from anilist_list)"),
|
|
482
|
+
mediaId: z
|
|
483
|
+
.number()
|
|
484
|
+
.int()
|
|
485
|
+
.positive()
|
|
486
|
+
.optional()
|
|
487
|
+
.describe("AniList media ID to remove from your list"),
|
|
488
|
+
})
|
|
489
|
+
.refine((data) => data.entryId !== undefined || data.mediaId !== undefined, {
|
|
490
|
+
message: "Provide either an entryId or a mediaId.",
|
|
481
491
|
});
|
|
482
492
|
/** Input for scoring a title against a user's taste profile */
|
|
483
|
-
export const ExplainInputSchema = z
|
|
493
|
+
export const ExplainInputSchema = z
|
|
494
|
+
.object({
|
|
484
495
|
mediaId: z
|
|
485
496
|
.number()
|
|
486
497
|
.int()
|
|
487
498
|
.positive()
|
|
499
|
+
.optional()
|
|
488
500
|
.describe("AniList media ID to evaluate against your taste profile"),
|
|
501
|
+
title: z
|
|
502
|
+
.string()
|
|
503
|
+
.optional()
|
|
504
|
+
.describe("Search by title if no ID is known"),
|
|
489
505
|
username: usernameSchema
|
|
490
506
|
.optional()
|
|
491
507
|
.describe("AniList username. Falls back to configured default if not provided."),
|
|
@@ -497,14 +513,23 @@ export const ExplainInputSchema = z.object({
|
|
|
497
513
|
.string()
|
|
498
514
|
.optional()
|
|
499
515
|
.describe('Optional mood context, e.g. "dark and brainy"'),
|
|
516
|
+
})
|
|
517
|
+
.refine((data) => data.mediaId !== undefined || data.title !== undefined, {
|
|
518
|
+
message: "Provide either a mediaId or a title.",
|
|
500
519
|
});
|
|
501
520
|
/** Input for finding titles similar to a specific anime or manga */
|
|
502
|
-
export const SimilarInputSchema = z
|
|
521
|
+
export const SimilarInputSchema = z
|
|
522
|
+
.object({
|
|
503
523
|
mediaId: z
|
|
504
524
|
.number()
|
|
505
525
|
.int()
|
|
506
526
|
.positive()
|
|
527
|
+
.optional()
|
|
507
528
|
.describe("AniList media ID to find similar titles for"),
|
|
529
|
+
title: z
|
|
530
|
+
.string()
|
|
531
|
+
.optional()
|
|
532
|
+
.describe("Search by title if no ID is known"),
|
|
508
533
|
limit: z
|
|
509
534
|
.number()
|
|
510
535
|
.int()
|
|
@@ -512,6 +537,9 @@ export const SimilarInputSchema = z.object({
|
|
|
512
537
|
.max(25)
|
|
513
538
|
.default(10)
|
|
514
539
|
.describe("Number of similar titles to return (default 10, max 25)"),
|
|
540
|
+
})
|
|
541
|
+
.refine((data) => data.mediaId !== undefined || data.title !== undefined, {
|
|
542
|
+
message: "Provide either a mediaId or a title.",
|
|
515
543
|
});
|
|
516
544
|
/** Input for searching staff/people by name */
|
|
517
545
|
export const StaffSearchInputSchema = z.object({
|
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);
|
package/dist/tools/recommend.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { anilistClient } from "../api/client.js";
|
|
3
3
|
import { BATCH_RELATIONS_QUERY, DISCOVER_MEDIA_QUERY, MEDIA_DETAILS_QUERY, RECOMMENDATIONS_QUERY, SEASONAL_MEDIA_QUERY, } from "../api/queries.js";
|
|
4
4
|
import { TasteInputSchema, PickInputSchema, SessionInputSchema, SequelAlertInputSchema, WatchOrderInputSchema, CompareInputSchema, WrappedInputSchema, ExplainInputSchema, SimilarInputSchema, } from "../schemas.js";
|
|
5
|
-
import { getTitle, getDefaultUsername, throwToolError, isNsfwEnabled, resolveSeasonYear, } from "../utils.js";
|
|
5
|
+
import { getTitle, getDefaultUsername, throwToolError, isNsfwEnabled, resolveSeasonYear, resolveAlias, } from "../utils.js";
|
|
6
|
+
import { SEARCH_MEDIA_QUERY } from "../api/queries.js";
|
|
6
7
|
import { buildTasteProfile, describeTasteProfile, } from "../engine/taste.js";
|
|
7
8
|
import { matchCandidates, explainMatch } from "../engine/matcher.js";
|
|
8
9
|
import { parseMood, hasMoodMatch, seasonalMoodSuggestions, } from "../engine/mood.js";
|
|
@@ -146,15 +147,19 @@ export function registerRecommendTools(server) {
|
|
|
146
147
|
const { season, year } = resolveSeasonYear(args.season, args.year);
|
|
147
148
|
sourceLabel = `${season} ${year} seasonal anime`;
|
|
148
149
|
candidatePromise = (async () => {
|
|
149
|
-
const
|
|
150
|
+
const vars = {
|
|
150
151
|
season,
|
|
151
152
|
seasonYear: year,
|
|
152
153
|
type: "ANIME",
|
|
153
154
|
sort: ["POPULARITY_DESC"],
|
|
154
155
|
perPage: 50,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
};
|
|
157
|
+
// Fetch pages 1 and 2 in parallel to cover 100 titles
|
|
158
|
+
const [p1, p2] = await Promise.all([
|
|
159
|
+
anilistClient.query(SEASONAL_MEDIA_QUERY, { ...vars, page: 1 }, { cache: "seasonal" }),
|
|
160
|
+
anilistClient.query(SEASONAL_MEDIA_QUERY, { ...vars, page: 2 }, { cache: "seasonal" }),
|
|
161
|
+
]);
|
|
162
|
+
return [...p1.Page.media, ...p2.Page.media];
|
|
158
163
|
})();
|
|
159
164
|
}
|
|
160
165
|
else if (source === "DISCOVER") {
|
|
@@ -783,11 +788,19 @@ export function registerRecommendTools(server) {
|
|
|
783
788
|
execute: async (args) => {
|
|
784
789
|
try {
|
|
785
790
|
const username = getDefaultUsername(args.username);
|
|
791
|
+
// Resolve title to media ID if needed
|
|
792
|
+
let mediaId = args.mediaId;
|
|
793
|
+
if (!mediaId && args.title) {
|
|
794
|
+
const search = resolveAlias(args.title);
|
|
795
|
+
const searchData = await anilistClient.query(SEARCH_MEDIA_QUERY, { search, type: "ANIME", page: 1, perPage: 1 }, { cache: "search" });
|
|
796
|
+
if (!searchData.Page.media.length) {
|
|
797
|
+
return `No results found for "${args.title}".`;
|
|
798
|
+
}
|
|
799
|
+
mediaId = searchData.Page.media[0].id;
|
|
800
|
+
}
|
|
786
801
|
// Fetch media details and taste profile in parallel
|
|
787
802
|
const [mediaData, { profile, entries }] = await Promise.all([
|
|
788
|
-
anilistClient.query(MEDIA_DETAILS_QUERY, {
|
|
789
|
-
id: args.mediaId,
|
|
790
|
-
}, { cache: "media" }),
|
|
803
|
+
anilistClient.query(MEDIA_DETAILS_QUERY, { id: mediaId }, { cache: "media" }),
|
|
791
804
|
profileForUser(username, args.type),
|
|
792
805
|
]);
|
|
793
806
|
const media = mediaData.Media;
|
|
@@ -874,15 +887,20 @@ export function registerRecommendTools(server) {
|
|
|
874
887
|
},
|
|
875
888
|
execute: async (args) => {
|
|
876
889
|
try {
|
|
890
|
+
// Resolve title to media ID if needed
|
|
891
|
+
let mediaId = args.mediaId;
|
|
892
|
+
if (!mediaId && args.title) {
|
|
893
|
+
const search = resolveAlias(args.title);
|
|
894
|
+
const searchData = await anilistClient.query(SEARCH_MEDIA_QUERY, { search, type: "ANIME", page: 1, perPage: 1 }, { cache: "search" });
|
|
895
|
+
if (!searchData.Page.media.length) {
|
|
896
|
+
return `No results found for "${args.title}".`;
|
|
897
|
+
}
|
|
898
|
+
mediaId = searchData.Page.media[0].id;
|
|
899
|
+
}
|
|
877
900
|
// Fetch source details and recommendations in parallel
|
|
878
901
|
const [detailsData, recsData] = await Promise.all([
|
|
879
|
-
anilistClient.query(MEDIA_DETAILS_QUERY, {
|
|
880
|
-
|
|
881
|
-
}, { cache: "media" }),
|
|
882
|
-
anilistClient.query(RECOMMENDATIONS_QUERY, {
|
|
883
|
-
id: args.mediaId,
|
|
884
|
-
perPage: 25,
|
|
885
|
-
}, { cache: "media" }),
|
|
902
|
+
anilistClient.query(MEDIA_DETAILS_QUERY, { id: mediaId }, { cache: "media" }),
|
|
903
|
+
anilistClient.query(RECOMMENDATIONS_QUERY, { id: mediaId, perPage: 25 }, { cache: "media" }),
|
|
886
904
|
]);
|
|
887
905
|
const source = detailsData.Media;
|
|
888
906
|
const sourceTitle = getTitle(source.title);
|
package/dist/tools/search.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { anilistClient } from "../api/client.js";
|
|
3
3
|
import { SEARCH_MEDIA_QUERY, MEDIA_DETAILS_QUERY, SEASONAL_MEDIA_QUERY, RECOMMENDATIONS_QUERY, } from "../api/queries.js";
|
|
4
4
|
import { SearchInputSchema, DetailsInputSchema, SeasonalInputSchema, RecommendationsInputSchema, } from "../schemas.js";
|
|
5
|
-
import { getTitle, truncateDescription, throwToolError, formatMediaSummary, paginationFooter, isNsfwEnabled, resolveAlias, resolveSeasonYear, } from "../utils.js";
|
|
5
|
+
import { getTitle, truncateDescription, throwToolError, formatMediaSummary, paginationFooter, isNsfwEnabled, resolveAlias, resolveSeasonYear, BROWSE_SORT_MAP, } from "../utils.js";
|
|
6
6
|
// Default to popularity for broad queries
|
|
7
7
|
const SEARCH_SORT = ["POPULARITY_DESC"];
|
|
8
8
|
// === Tool Registration ===
|
|
@@ -171,17 +171,12 @@ export function registerSearchTools(server) {
|
|
|
171
171
|
execute: async (args) => {
|
|
172
172
|
try {
|
|
173
173
|
const { season, year } = resolveSeasonYear(args.season, args.year);
|
|
174
|
-
const sortMap = {
|
|
175
|
-
POPULARITY: ["POPULARITY_DESC"],
|
|
176
|
-
SCORE: ["SCORE_DESC"],
|
|
177
|
-
TRENDING: ["TRENDING_DESC"],
|
|
178
|
-
};
|
|
179
174
|
const data = await anilistClient.query(SEASONAL_MEDIA_QUERY, {
|
|
180
175
|
season,
|
|
181
176
|
seasonYear: year,
|
|
182
177
|
type: "ANIME",
|
|
183
178
|
isAdult: args.isAdult ? undefined : false,
|
|
184
|
-
sort:
|
|
179
|
+
sort: BROWSE_SORT_MAP[args.sort] ?? BROWSE_SORT_MAP.POPULARITY,
|
|
185
180
|
page: args.page,
|
|
186
181
|
perPage: args.limit,
|
|
187
182
|
}, { cache: "seasonal" });
|
package/dist/tools/write.js
CHANGED
|
@@ -4,7 +4,7 @@ import { SAVE_MEDIA_LIST_ENTRY_MUTATION, DELETE_MEDIA_LIST_ENTRY_MUTATION, TOGGL
|
|
|
4
4
|
import { pushUndo, popUndo } from "../engine/undo.js";
|
|
5
5
|
import { invalidateUserProfiles } from "../engine/profile-cache.js";
|
|
6
6
|
import { UpdateProgressInputSchema, AddToListInputSchema, RateInputSchema, DeleteFromListInputSchema, FavouriteInputSchema, PostActivityInputSchema, UndoInputSchema, UnscoredInputSchema, BatchUpdateInputSchema, } from "../schemas.js";
|
|
7
|
-
import { throwToolError, formatScore,
|
|
7
|
+
import { throwToolError, formatScore, getScoreFormat, getTitle, getDefaultUsername, } from "../utils.js";
|
|
8
8
|
// === Auth Guard ===
|
|
9
9
|
/** Guard against unauthenticated write attempts */
|
|
10
10
|
function requireAuth() {
|
|
@@ -69,8 +69,9 @@ export function registerWriteTools(server) {
|
|
|
69
69
|
status: args.status ?? "CURRENT",
|
|
70
70
|
};
|
|
71
71
|
const data = await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, variables, { cache: null });
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const viewerName = await getViewerName();
|
|
73
|
+
anilistClient.invalidateUser(viewerName);
|
|
74
|
+
invalidateUserProfiles(viewerName);
|
|
74
75
|
const entry = data.SaveMediaListEntry;
|
|
75
76
|
// Track for undo
|
|
76
77
|
pushUndo({
|
|
@@ -122,13 +123,11 @@ export function registerWriteTools(server) {
|
|
|
122
123
|
variables.scoreRaw = Math.round(args.score * 10);
|
|
123
124
|
const [data, scoreFmt] = await Promise.all([
|
|
124
125
|
anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, variables, { cache: null }),
|
|
125
|
-
|
|
126
|
-
const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
|
|
127
|
-
return data.Viewer.mediaListOptions.scoreFormat;
|
|
128
|
-
}),
|
|
126
|
+
getScoreFormat(),
|
|
129
127
|
]);
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
const viewerName = await getViewerName();
|
|
129
|
+
anilistClient.invalidateUser(viewerName);
|
|
130
|
+
invalidateUserProfiles(viewerName);
|
|
132
131
|
const entry = data.SaveMediaListEntry;
|
|
133
132
|
// Track for undo
|
|
134
133
|
pushUndo({
|
|
@@ -175,13 +174,11 @@ export function registerWriteTools(server) {
|
|
|
175
174
|
const before = await snapshotByMediaId(args.mediaId);
|
|
176
175
|
const [data, scoreFmt] = await Promise.all([
|
|
177
176
|
anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, { mediaId: args.mediaId, scoreRaw: Math.round(args.score * 10) }, { cache: null }),
|
|
178
|
-
|
|
179
|
-
const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
|
|
180
|
-
return data.Viewer.mediaListOptions.scoreFormat;
|
|
181
|
-
}),
|
|
177
|
+
getScoreFormat(),
|
|
182
178
|
]);
|
|
183
|
-
|
|
184
|
-
|
|
179
|
+
const viewerName = await getViewerName();
|
|
180
|
+
anilistClient.invalidateUser(viewerName);
|
|
181
|
+
invalidateUserProfiles(viewerName);
|
|
185
182
|
const entry = data.SaveMediaListEntry;
|
|
186
183
|
// Track for undo
|
|
187
184
|
if (before) {
|
|
@@ -209,7 +206,7 @@ export function registerWriteTools(server) {
|
|
|
209
206
|
server.addTool({
|
|
210
207
|
name: "anilist_delete_from_list",
|
|
211
208
|
description: "Remove an entry from your anime or manga list. " +
|
|
212
|
-
"
|
|
209
|
+
"Pass either a list entry ID or a media ID. " +
|
|
213
210
|
"Requires ANILIST_TOKEN.",
|
|
214
211
|
parameters: DeleteFromListInputSchema,
|
|
215
212
|
annotations: {
|
|
@@ -222,13 +219,26 @@ export function registerWriteTools(server) {
|
|
|
222
219
|
execute: async (args) => {
|
|
223
220
|
try {
|
|
224
221
|
requireAuth();
|
|
222
|
+
// Resolve mediaId to entryId if needed
|
|
223
|
+
let entryId = args.entryId;
|
|
224
|
+
if (!entryId && args.mediaId) {
|
|
225
|
+
const snapshot = await snapshotByMediaId(args.mediaId);
|
|
226
|
+
if (!snapshot) {
|
|
227
|
+
return `Media ${args.mediaId} is not on your list.`;
|
|
228
|
+
}
|
|
229
|
+
entryId = snapshot.id;
|
|
230
|
+
}
|
|
231
|
+
if (!entryId) {
|
|
232
|
+
return "Provide either an entryId or a mediaId.";
|
|
233
|
+
}
|
|
225
234
|
// Snapshot before deletion
|
|
226
|
-
const before = await snapshotByEntryId(
|
|
227
|
-
const data = await anilistClient.query(DELETE_MEDIA_LIST_ENTRY_MUTATION, { id:
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
const before = await snapshotByEntryId(entryId);
|
|
236
|
+
const data = await anilistClient.query(DELETE_MEDIA_LIST_ENTRY_MUTATION, { id: entryId }, { cache: null });
|
|
237
|
+
const viewerName = await getViewerName();
|
|
238
|
+
anilistClient.invalidateUser(viewerName);
|
|
239
|
+
invalidateUserProfiles(viewerName);
|
|
230
240
|
if (!data.DeleteMediaListEntry.deleted) {
|
|
231
|
-
return `Entry ${
|
|
241
|
+
return `Entry ${entryId} was not found or already removed.`;
|
|
232
242
|
}
|
|
233
243
|
// Track for undo
|
|
234
244
|
if (before) {
|
|
@@ -236,13 +246,13 @@ export function registerWriteTools(server) {
|
|
|
236
246
|
operation: { type: "delete", before },
|
|
237
247
|
toolName: "anilist_delete_from_list",
|
|
238
248
|
timestamp: Date.now(),
|
|
239
|
-
description: `Deleted entry ${
|
|
249
|
+
description: `Deleted entry ${entryId} (media ${before.mediaId})`,
|
|
240
250
|
});
|
|
241
251
|
}
|
|
242
252
|
const hint = before
|
|
243
253
|
? `\n(Deleted ${before.status} entry - say "undo" to restore)`
|
|
244
254
|
: "";
|
|
245
|
-
return `Entry ${
|
|
255
|
+
return `Entry ${entryId} deleted from your list.${hint}`;
|
|
246
256
|
}
|
|
247
257
|
catch (error) {
|
|
248
258
|
return throwToolError(error, "deleting from list");
|
|
@@ -278,15 +288,17 @@ export function registerWriteTools(server) {
|
|
|
278
288
|
scoreRaw: op.before.score * 10,
|
|
279
289
|
progress: op.before.progress,
|
|
280
290
|
}, { cache: null });
|
|
281
|
-
|
|
282
|
-
|
|
291
|
+
const viewerName = await getViewerName();
|
|
292
|
+
anilistClient.invalidateUser(viewerName);
|
|
293
|
+
invalidateUserProfiles(viewerName);
|
|
283
294
|
return `Undone: restored media ${op.before.mediaId} to ${op.before.status}, progress ${op.before.progress}, score ${op.before.score}.`;
|
|
284
295
|
}
|
|
285
296
|
if (op.type === "create") {
|
|
286
297
|
// Delete the newly created entry
|
|
287
298
|
await anilistClient.query(DELETE_MEDIA_LIST_ENTRY_MUTATION, { id: op.entryId }, { cache: null });
|
|
288
|
-
|
|
289
|
-
|
|
299
|
+
const viewerName = await getViewerName();
|
|
300
|
+
anilistClient.invalidateUser(viewerName);
|
|
301
|
+
invalidateUserProfiles(viewerName);
|
|
290
302
|
return `Undone: removed media ${op.mediaId} from your list.`;
|
|
291
303
|
}
|
|
292
304
|
if (op.type === "delete") {
|
|
@@ -297,8 +309,9 @@ export function registerWriteTools(server) {
|
|
|
297
309
|
scoreRaw: op.before.score * 10,
|
|
298
310
|
progress: op.before.progress,
|
|
299
311
|
}, { cache: null });
|
|
300
|
-
|
|
301
|
-
|
|
312
|
+
const viewerName = await getViewerName();
|
|
313
|
+
anilistClient.invalidateUser(viewerName);
|
|
314
|
+
invalidateUserProfiles(viewerName);
|
|
302
315
|
return `Undone: restored media ${op.before.mediaId} to ${op.before.status}, progress ${op.before.progress}.`;
|
|
303
316
|
}
|
|
304
317
|
if (op.type === "batch") {
|
|
@@ -318,8 +331,9 @@ export function registerWriteTools(server) {
|
|
|
318
331
|
// Continue on individual failures
|
|
319
332
|
}
|
|
320
333
|
}
|
|
321
|
-
|
|
322
|
-
|
|
334
|
+
const viewerName = await getViewerName();
|
|
335
|
+
anilistClient.invalidateUser(viewerName);
|
|
336
|
+
invalidateUserProfiles(viewerName);
|
|
323
337
|
return `Undone: restored ${restored}/${op.entries.length} entries to their previous state.`;
|
|
324
338
|
}
|
|
325
339
|
return "Unknown undo operation type.";
|
|
@@ -364,7 +378,7 @@ export function registerWriteTools(server) {
|
|
|
364
378
|
requireAuth();
|
|
365
379
|
const variables = { [FAVOURITE_VAR_MAP[args.type]]: args.id };
|
|
366
380
|
const data = await anilistClient.query(TOGGLE_FAVOURITE_MUTATION, variables, { cache: null });
|
|
367
|
-
anilistClient.
|
|
381
|
+
anilistClient.invalidateUser(await getViewerName());
|
|
368
382
|
// Check if entity is now in favourites (added) or absent (removed)
|
|
369
383
|
const field = FAVOURITE_FIELD_MAP[args.type];
|
|
370
384
|
const isFavourited = data.ToggleFavourite[field].nodes.some((n) => n.id === args.id);
|
|
@@ -396,7 +410,7 @@ export function registerWriteTools(server) {
|
|
|
396
410
|
try {
|
|
397
411
|
requireAuth();
|
|
398
412
|
const data = await anilistClient.query(SAVE_TEXT_ACTIVITY_MUTATION, { text: args.text }, { cache: null });
|
|
399
|
-
anilistClient.
|
|
413
|
+
anilistClient.invalidateUser(await getViewerName());
|
|
400
414
|
const activity = data.SaveTextActivity;
|
|
401
415
|
const dateStr = new Date(activity.createdAt * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
402
416
|
return [
|
|
@@ -562,8 +576,9 @@ export function registerWriteTools(server) {
|
|
|
562
576
|
failures++;
|
|
563
577
|
}
|
|
564
578
|
}
|
|
565
|
-
|
|
566
|
-
|
|
579
|
+
const viewerName = await getViewerName();
|
|
580
|
+
anilistClient.invalidateUser(viewerName);
|
|
581
|
+
invalidateUserProfiles(viewerName);
|
|
567
582
|
// Track batch for undo
|
|
568
583
|
if (snapshots.length > 0) {
|
|
569
584
|
pushUndo({
|
package/dist/utils.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ export declare function paginationFooter(page: number, limit: number, total: num
|
|
|
18
18
|
export declare function formatMediaSummary(media: AniListMedia): string;
|
|
19
19
|
/** Detect score format from env override or API fallback */
|
|
20
20
|
export declare function detectScoreFormat(fetchFormat: () => Promise<ScoreFormat>): Promise<ScoreFormat>;
|
|
21
|
+
/** Fetch score format for a user (by username) or the authenticated viewer */
|
|
22
|
+
export declare function getScoreFormat(username?: string): Promise<ScoreFormat>;
|
|
23
|
+
/** Sort direction map for browse/seasonal tools */
|
|
24
|
+
export declare const BROWSE_SORT_MAP: Record<string, string[]>;
|
|
21
25
|
/** Resolve season and year, defaulting to current if not provided */
|
|
22
26
|
export declare function resolveSeasonYear(season?: string, year?: number): {
|
|
23
27
|
season: string;
|
package/dist/utils.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Formatting and resolution helpers. */
|
|
2
2
|
import { UserError } from "fastmcp";
|
|
3
|
+
import { anilistClient } from "./api/client.js";
|
|
4
|
+
import { USER_STATS_QUERY, VIEWER_QUERY } from "./api/queries.js";
|
|
3
5
|
/** Best available title, respecting ANILIST_TITLE_LANGUAGE preference */
|
|
4
6
|
export function getTitle(title) {
|
|
5
7
|
const pref = process.env.ANILIST_TITLE_LANGUAGE?.toLowerCase();
|
|
@@ -136,6 +138,23 @@ export async function detectScoreFormat(fetchFormat) {
|
|
|
136
138
|
return "POINT_10";
|
|
137
139
|
}
|
|
138
140
|
}
|
|
141
|
+
/** Fetch score format for a user (by username) or the authenticated viewer */
|
|
142
|
+
export async function getScoreFormat(username) {
|
|
143
|
+
return detectScoreFormat(async () => {
|
|
144
|
+
if (username) {
|
|
145
|
+
const data = await anilistClient.query(USER_STATS_QUERY, { name: username }, { cache: "stats" });
|
|
146
|
+
return data.User.mediaListOptions.scoreFormat;
|
|
147
|
+
}
|
|
148
|
+
const data = await anilistClient.query(VIEWER_QUERY, {}, { cache: "stats" });
|
|
149
|
+
return data.Viewer.mediaListOptions.scoreFormat;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/** Sort direction map for browse/seasonal tools */
|
|
153
|
+
export const BROWSE_SORT_MAP = {
|
|
154
|
+
SCORE: ["SCORE_DESC"],
|
|
155
|
+
POPULARITY: ["POPULARITY_DESC"],
|
|
156
|
+
TRENDING: ["TRENDING_DESC"],
|
|
157
|
+
};
|
|
139
158
|
/** Resolve season and year, defaulting to current if not provided */
|
|
140
159
|
export function resolveSeasonYear(season, year) {
|
|
141
160
|
const now = new Date();
|
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ani-mcp",
|
|
3
3
|
"mcpName": "io.github.gavxm/ani-mcp",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.2",
|
|
5
5
|
"description": "A smart [MCP](https://modelcontextprotocol.io) server for [AniList](https://anilist.co) that gets your anime/manga taste - not just API calls.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/gavxm/ani-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.8.
|
|
9
|
+
"version": "0.8.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ani-mcp",
|
|
14
|
-
"version": "0.8.
|
|
14
|
+
"version": "0.8.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|