ani-mcp 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,6 +114,7 @@ Works with any MCP-compatible client.
114
114
  | `anilist_watch_order` | Viewing order for a franchise |
115
115
  | `anilist_session` | Plan a viewing session within a time budget |
116
116
  | `anilist_mal_import` | Import a MyAnimeList user's list and generate recommendations |
117
+ | `anilist_kitsu_import` | Import a Kitsu user's list and generate recommendations |
117
118
 
118
119
  ### Cards
119
120
 
@@ -39,6 +39,11 @@ declare class AniListClient {
39
39
  fetchList(username: string, type: string, status?: string, sort?: string[]): Promise<AniListMediaListEntry[]>;
40
40
  /** Invalidate the entire query cache */
41
41
  clearCache(): void;
42
+ /** Cache size and capacity for health checks */
43
+ cacheStats(): {
44
+ size: number;
45
+ maxSize: number;
46
+ };
42
47
  /** Evict cache entries related to a specific user (lists and stats) */
43
48
  invalidateUser(username: string): void;
44
49
  /** Retries with exponential backoff via p-retry */
@@ -121,6 +121,10 @@ class AniListClient {
121
121
  clearCache() {
122
122
  queryCache.clear();
123
123
  }
124
+ /** Cache size and capacity for health checks */
125
+ cacheStats() {
126
+ return { size: queryCache.size, maxSize: 500 };
127
+ }
124
128
  /** Evict cache entries related to a specific user (lists and stats) */
125
129
  invalidateUser(username) {
126
130
  const needle = `"${username}"`;
@@ -0,0 +1,39 @@
1
+ /** Kitsu API client for read-only list import */
2
+ export interface KitsuLibraryEntry {
3
+ id: string;
4
+ attributes: {
5
+ status: string;
6
+ ratingTwenty: number | null;
7
+ progress: number;
8
+ };
9
+ relationships: {
10
+ anime: {
11
+ data: {
12
+ type: string;
13
+ id: string;
14
+ } | null;
15
+ };
16
+ };
17
+ }
18
+ export interface KitsuAnime {
19
+ id: string;
20
+ attributes: {
21
+ canonicalTitle: string;
22
+ episodeCount: number | null;
23
+ averageRating: string | null;
24
+ subtype: string;
25
+ };
26
+ }
27
+ export interface KitsuCategory {
28
+ id: string;
29
+ attributes: {
30
+ title: string;
31
+ };
32
+ }
33
+ /** Map Kitsu subtype to AniList format */
34
+ export declare function mapKitsuFormat(subtype: string): string;
35
+ /** Fetch a Kitsu user's completed anime library */
36
+ export declare function fetchKitsuList(username: string, maxPages?: number): Promise<{
37
+ entries: KitsuLibraryEntry[];
38
+ anime: Map<string, KitsuAnime>;
39
+ }>;
@@ -0,0 +1,87 @@
1
+ /** Kitsu API client for read-only list import */
2
+ import pThrottle from "p-throttle";
3
+ import pRetry, { AbortError } from "p-retry";
4
+ const KITSU_BASE = process.env.KITSU_API_URL || "https://kitsu.io/api/edge";
5
+ const FETCH_TIMEOUT_MS = 15_000;
6
+ const PAGE_LIMIT = 20;
7
+ // Kitsu has no documented rate limit; be conservative
8
+ const throttle = pThrottle({
9
+ limit: process.env.VITEST ? 10_000 : 5,
10
+ interval: 1_000,
11
+ });
12
+ const throttled = throttle(() => { });
13
+ // === Format Mapping ===
14
+ const KITSU_TO_ANILIST_FORMAT = {
15
+ TV: "TV",
16
+ movie: "MOVIE",
17
+ OVA: "OVA",
18
+ ONA: "ONA",
19
+ special: "SPECIAL",
20
+ music: "MUSIC",
21
+ };
22
+ /** Map Kitsu subtype to AniList format */
23
+ export function mapKitsuFormat(subtype) {
24
+ return KITSU_TO_ANILIST_FORMAT[subtype] ?? "TV";
25
+ }
26
+ // === Client ===
27
+ /** Resolve a Kitsu username to a user ID */
28
+ async function resolveUserId(username) {
29
+ return pRetry(async () => {
30
+ await throttled();
31
+ const url = `${KITSU_BASE}/users?filter[name]=${encodeURIComponent(username)}&fields[users]=id,name&page[limit]=1`;
32
+ const response = await fetch(url, {
33
+ headers: { Accept: "application/vnd.api+json" },
34
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
35
+ });
36
+ if (!response.ok) {
37
+ throw new Error(`Kitsu API error (HTTP ${response.status})`);
38
+ }
39
+ const json = (await response.json());
40
+ if (!json.data.length) {
41
+ throw new AbortError(`Kitsu user "${username}" not found.`);
42
+ }
43
+ return json.data[0].id;
44
+ }, { retries: 3 });
45
+ }
46
+ /** Fetch a Kitsu user's completed anime library */
47
+ export async function fetchKitsuList(username, maxPages = 10) {
48
+ const userId = await resolveUserId(username);
49
+ const entries = [];
50
+ const animeMap = new Map();
51
+ let url = `${KITSU_BASE}/library-entries?filter[userId]=${userId}` +
52
+ `&filter[status]=completed&filter[kind]=anime` +
53
+ `&page[limit]=${PAGE_LIMIT}` +
54
+ `&include=anime` +
55
+ `&fields[libraryEntries]=status,ratingTwenty,progress,anime` +
56
+ `&fields[anime]=canonicalTitle,episodeCount,averageRating,subtype`;
57
+ for (let page = 0; page < maxPages && url; page++) {
58
+ const data = await fetchPage(url);
59
+ entries.push(...data.data);
60
+ // Index included anime
61
+ if (data.included) {
62
+ for (const inc of data.included) {
63
+ if (inc.id && "canonicalTitle" in (inc.attributes ?? {})) {
64
+ animeMap.set(inc.id, inc);
65
+ }
66
+ }
67
+ }
68
+ url = data.links?.next ?? null;
69
+ }
70
+ return { entries, anime: animeMap };
71
+ }
72
+ async function fetchPage(url) {
73
+ return pRetry(async () => {
74
+ await throttled();
75
+ const response = await fetch(url, {
76
+ headers: { Accept: "application/vnd.api+json" },
77
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
78
+ });
79
+ if (!response.ok) {
80
+ if (response.status === 404) {
81
+ throw new AbortError("Kitsu user not found.");
82
+ }
83
+ throw new Error(`Kitsu API error (HTTP ${response.status})`);
84
+ }
85
+ return (await response.json());
86
+ }, { retries: 3 });
87
+ }
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ if (!process.env.ANILIST_TOKEN) {
23
23
  }
24
24
  const server = new FastMCP({
25
25
  name: "ani-mcp",
26
- version: "0.12.0",
26
+ version: "0.13.0",
27
27
  });
28
28
  registerSearchTools(server);
29
29
  registerListTools(server);
package/dist/resources.js CHANGED
@@ -91,6 +91,37 @@ export function registerResources(server) {
91
91
  }
92
92
  },
93
93
  });
94
+ // === Health Check ===
95
+ server.addResource({
96
+ uri: "anilist://status",
97
+ name: "Server Status",
98
+ description: "Health check showing API connectivity, auth status, cache state, and server version.",
99
+ mimeType: "text/plain",
100
+ async load() {
101
+ const lines = ["# ani-mcp Status", ""];
102
+ // Server version
103
+ lines.push(`Version: 0.13.0`);
104
+ // Auth status
105
+ const hasToken = Boolean(process.env.ANILIST_TOKEN);
106
+ const hasUsername = Boolean(process.env.ANILIST_USERNAME);
107
+ lines.push(`Auth: ${hasToken ? "token configured" : "no token (read-only mode)"}`);
108
+ lines.push(`Username: ${hasUsername ? process.env.ANILIST_USERNAME : "not configured"}`);
109
+ // API connectivity
110
+ try {
111
+ const start = Date.now();
112
+ await anilistClient.query("query Ping { Viewer { id } }", {}, { cache: null });
113
+ const latency = Date.now() - start;
114
+ lines.push(`API: connected (${latency}ms)`);
115
+ }
116
+ catch {
117
+ lines.push("API: unreachable");
118
+ }
119
+ // Cache stats
120
+ const cacheStats = anilistClient.cacheStats();
121
+ lines.push(`Cache: ${cacheStats.size}/${cacheStats.maxSize} entries`);
122
+ return { text: lines.join("\n") };
123
+ },
124
+ });
94
125
  }
95
126
  // === Formatting Helpers ===
96
127
  /** Format a taste profile with detailed breakdowns */
package/dist/schemas.d.ts CHANGED
@@ -260,6 +260,12 @@ export declare const MalImportInputSchema: z.ZodObject<{
260
260
  limit: z.ZodDefault<z.ZodNumber>;
261
261
  }, z.core.$strip>;
262
262
  export type MalImportInput = z.infer<typeof MalImportInputSchema>;
263
+ /** Input for importing a Kitsu user's completed list */
264
+ export declare const KitsuImportInputSchema: z.ZodObject<{
265
+ kitsuUsername: z.ZodString;
266
+ limit: z.ZodDefault<z.ZodNumber>;
267
+ }, z.core.$strip>;
268
+ export type KitsuImportInput = z.infer<typeof KitsuImportInputSchema>;
263
269
  /** Input for character search */
264
270
  export declare const CharacterSearchInputSchema: z.ZodObject<{
265
271
  query: z.ZodString;
package/dist/schemas.js CHANGED
@@ -449,6 +449,21 @@ export const MalImportInputSchema = z.object({
449
449
  .default(5)
450
450
  .describe("Number of recommendations to return (default 5, max 15)"),
451
451
  });
452
+ /** Input for importing a Kitsu user's completed list */
453
+ export const KitsuImportInputSchema = z.object({
454
+ kitsuUsername: z
455
+ .string()
456
+ .min(2)
457
+ .max(30)
458
+ .describe("Kitsu username to import"),
459
+ limit: z
460
+ .number()
461
+ .int()
462
+ .min(1)
463
+ .max(15)
464
+ .default(5)
465
+ .describe("Number of recommendations to return (default 5, max 15)"),
466
+ });
452
467
  /** Input for character search */
453
468
  export const CharacterSearchInputSchema = z.object({
454
469
  query: z
@@ -1,8 +1,9 @@
1
1
  /** Import tools: cross-platform list import for recommendations */
2
2
  import { fetchMalList, mapMalGenre, mapMalFormat, } from "../api/mal-client.js";
3
+ import { fetchKitsuList, mapKitsuFormat, } from "../api/kitsu-client.js";
3
4
  import { anilistClient } from "../api/client.js";
4
5
  import { DISCOVER_MEDIA_QUERY } from "../api/queries.js";
5
- import { MalImportInputSchema } from "../schemas.js";
6
+ import { MalImportInputSchema, KitsuImportInputSchema } from "../schemas.js";
6
7
  import { throwToolError, formatMediaSummary } from "../utils.js";
7
8
  import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "../engine/taste.js";
8
9
  import { matchCandidates } from "../engine/matcher.js";
@@ -72,8 +73,120 @@ export function registerImportTools(server) {
72
73
  }
73
74
  },
74
75
  });
76
+ // === Kitsu Import ===
77
+ server.addTool({
78
+ name: "anilist_kitsu_import",
79
+ description: "Import a Kitsu user's completed anime list and generate " +
80
+ "personalized recommendations based on their taste. No auth needed. " +
81
+ "Use when the user mentions their Kitsu account or wants recs from Kitsu history. " +
82
+ "Returns a taste profile summary and recommended titles from AniList.",
83
+ parameters: KitsuImportInputSchema,
84
+ annotations: {
85
+ title: "Kitsu Import",
86
+ readOnlyHint: true,
87
+ destructiveHint: false,
88
+ openWorldHint: true,
89
+ },
90
+ execute: async (args) => {
91
+ try {
92
+ const { entries, anime } = await fetchKitsuList(args.kitsuUsername);
93
+ if (entries.length === 0) {
94
+ return `No completed anime found for Kitsu user "${args.kitsuUsername}".`;
95
+ }
96
+ // Convert to AniList format for taste engine
97
+ const converted = kitsuEntriesToAniList(entries, anime);
98
+ const profile = buildTasteProfile(converted);
99
+ const lines = [
100
+ `# Kitsu Import: ${args.kitsuUsername}`,
101
+ `Imported ${entries.length} completed anime from Kitsu.`,
102
+ "",
103
+ "## Taste Profile",
104
+ describeTasteProfile(profile, args.kitsuUsername),
105
+ ...formatTasteProfileText(profile),
106
+ ];
107
+ // Fetch AniList candidates using top genres
108
+ const topGenres = profile.genres.slice(0, 3).map((g) => g.name);
109
+ if (topGenres.length > 0) {
110
+ const candidates = await fetchDiscoverCandidates(topGenres);
111
+ // Filter out titles already on Kitsu list
112
+ const kitsuTitles = new Set(Array.from(anime.values()).map((a) => a.attributes.canonicalTitle.toLowerCase()));
113
+ const filtered = candidates.filter((m) => !kitsuTitles.has((m.title.english ?? m.title.romaji ?? "").toLowerCase()));
114
+ const ranked = matchCandidates(filtered, profile).slice(0, args.limit);
115
+ lines.push("", "## Recommendations", "");
116
+ if (ranked.length === 0) {
117
+ lines.push("No new recommendations found.");
118
+ }
119
+ else {
120
+ for (let i = 0; i < ranked.length; i++) {
121
+ const r = ranked[i];
122
+ lines.push(`${i + 1}. ${formatMediaSummary(r.media)}`);
123
+ if (r.reasons.length > 0) {
124
+ lines.push(` Why: ${r.reasons.slice(0, 3).join(", ")}`);
125
+ }
126
+ lines.push("");
127
+ }
128
+ }
129
+ }
130
+ return lines.join("\n");
131
+ }
132
+ catch (error) {
133
+ return throwToolError(error, "importing Kitsu list");
134
+ }
135
+ },
136
+ });
75
137
  }
76
138
  // === Helpers ===
139
+ // Convert Kitsu entries to AniList format for the taste engine
140
+ function kitsuEntriesToAniList(entries, animeMap) {
141
+ return entries
142
+ .filter((e) => e.attributes.ratingTwenty !== null && e.attributes.ratingTwenty > 0)
143
+ .map((e) => {
144
+ const animeId = e.relationships.anime.data?.id ?? "0";
145
+ const anime = animeMap.get(animeId);
146
+ const title = anime?.attributes.canonicalTitle ?? "Unknown";
147
+ // ratingTwenty is 2-20 scale; convert to 1-10
148
+ const score = Math.round((e.attributes.ratingTwenty ?? 0) / 2);
149
+ const avgRating = anime?.attributes.averageRating;
150
+ return {
151
+ id: parseInt(animeId, 10),
152
+ score,
153
+ progress: e.attributes.progress,
154
+ progressVolumes: 0,
155
+ status: "COMPLETED",
156
+ updatedAt: 0,
157
+ startedAt: { year: null, month: null, day: null },
158
+ completedAt: { year: null, month: null, day: null },
159
+ notes: null,
160
+ media: {
161
+ id: parseInt(animeId, 10),
162
+ type: "ANIME",
163
+ title: { romaji: title, english: title, native: null },
164
+ format: mapKitsuFormat(anime?.attributes.subtype ?? "TV"),
165
+ status: "FINISHED",
166
+ episodes: anime?.attributes.episodeCount ?? null,
167
+ duration: null,
168
+ chapters: null,
169
+ volumes: null,
170
+ meanScore: avgRating ? Math.round(parseFloat(avgRating)) : null,
171
+ averageScore: null,
172
+ popularity: null,
173
+ genres: [],
174
+ tags: [],
175
+ season: null,
176
+ seasonYear: null,
177
+ startDate: { year: null, month: null, day: null },
178
+ endDate: { year: null, month: null, day: null },
179
+ studios: { nodes: [] },
180
+ source: null,
181
+ isAdult: false,
182
+ coverImage: { large: null, extraLarge: null },
183
+ trailer: null,
184
+ siteUrl: `https://kitsu.io/anime/${animeId}`,
185
+ description: null,
186
+ },
187
+ };
188
+ });
189
+ }
77
190
  // Convert MAL entries to AniList format for the taste engine
78
191
  function malEntriesToAniList(entries) {
79
192
  return entries
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ani-mcp",
4
- "version": "0.12.0",
4
+ "version": "0.13.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": {
@@ -179,6 +179,30 @@
179
179
  },
180
180
  {
181
181
  "name": "anilist_compat_card"
182
+ },
183
+ {
184
+ "name": "anilist_mal_import"
185
+ },
186
+ {
187
+ "name": "anilist_kitsu_import"
188
+ },
189
+ {
190
+ "name": "anilist_lookup"
191
+ },
192
+ {
193
+ "name": "anilist_airing"
194
+ },
195
+ {
196
+ "name": "anilist_group_pick"
197
+ },
198
+ {
199
+ "name": "anilist_shared_planning"
200
+ },
201
+ {
202
+ "name": "anilist_follow_suggestions"
203
+ },
204
+ {
205
+ "name": "anilist_react"
182
206
  }
183
207
  ]
184
208
  }
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.12.0",
4
+ "version": "0.13.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.12.0",
9
+ "version": "0.13.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ani-mcp",
14
- "version": "0.12.0",
14
+ "version": "0.13.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },