ani-mcp 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/api/client.d.ts +45 -0
  4. package/dist/api/client.js +192 -0
  5. package/dist/api/queries.d.ts +30 -0
  6. package/dist/api/queries.js +401 -0
  7. package/dist/engine/compare.d.ts +18 -0
  8. package/dist/engine/compare.js +72 -0
  9. package/dist/engine/matcher.d.ts +12 -0
  10. package/dist/engine/matcher.js +146 -0
  11. package/dist/engine/mood.d.ts +17 -0
  12. package/dist/engine/mood.js +165 -0
  13. package/dist/engine/taste.d.ts +30 -0
  14. package/dist/engine/taste.js +202 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +26 -0
  17. package/dist/intelligence/matcher.d.ts +12 -0
  18. package/dist/intelligence/matcher.js +134 -0
  19. package/dist/intelligence/mood.d.ts +17 -0
  20. package/dist/intelligence/mood.js +125 -0
  21. package/dist/intelligence/taste.d.ts +30 -0
  22. package/dist/intelligence/taste.js +172 -0
  23. package/dist/schemas.d.ts +190 -0
  24. package/dist/schemas.js +316 -0
  25. package/dist/tools/discover.d.ts +4 -0
  26. package/dist/tools/discover.js +94 -0
  27. package/dist/tools/info.d.ts +4 -0
  28. package/dist/tools/info.js +172 -0
  29. package/dist/tools/lists.d.ts +4 -0
  30. package/dist/tools/lists.js +178 -0
  31. package/dist/tools/recommend.d.ts +4 -0
  32. package/dist/tools/recommend.js +405 -0
  33. package/dist/tools/search.d.ts +4 -0
  34. package/dist/tools/search.js +243 -0
  35. package/dist/tools/smart.d.ts +4 -0
  36. package/dist/tools/smart.js +311 -0
  37. package/dist/types.d.ts +303 -0
  38. package/dist/types.js +2 -0
  39. package/dist/utils.d.ts +12 -0
  40. package/dist/utils.js +69 -0
  41. package/package.json +60 -0
@@ -0,0 +1,316 @@
1
+ /** Zod input schemas for MCP tool validation. */
2
+ import { z } from "zod";
3
+ // AniList usernames: 2-20 chars, alphanumeric + underscores
4
+ const usernameSchema = z
5
+ .string()
6
+ .min(2)
7
+ .max(20)
8
+ .regex(/^[a-zA-Z0-9_]+$/, "Letters, numbers, and underscores only");
9
+ /** Input for searching anime or manga by title and filters */
10
+ export const SearchInputSchema = z.object({
11
+ query: z
12
+ .string()
13
+ .min(1, "Search query cannot be empty")
14
+ .describe('Search term, e.g. "steins gate", "one piece", "chainsaw man"'),
15
+ type: z
16
+ .enum(["ANIME", "MANGA"])
17
+ .default("ANIME")
18
+ .describe("Search for anime or manga"),
19
+ genre: z
20
+ .string()
21
+ .optional()
22
+ .describe('Filter by genre, e.g. "Action", "Romance", "Thriller"'),
23
+ year: z
24
+ .number()
25
+ .int()
26
+ .min(1940)
27
+ .max(2030)
28
+ .optional()
29
+ .describe("Filter by release year"),
30
+ format: z
31
+ .enum([
32
+ "TV",
33
+ "MOVIE",
34
+ "OVA",
35
+ "ONA",
36
+ "SPECIAL",
37
+ "MANGA",
38
+ "NOVEL",
39
+ "ONE_SHOT",
40
+ ])
41
+ .optional()
42
+ .describe("Filter by format (TV, MOVIE, etc.)"),
43
+ isAdult: z
44
+ .boolean()
45
+ .default(false)
46
+ .describe("Include adult (18+) content in results"),
47
+ // Capped at 25. Sending 100 results to an LLM wastes context window.
48
+ limit: z
49
+ .number()
50
+ .int()
51
+ .min(1)
52
+ .max(25)
53
+ .default(10)
54
+ .describe("Number of results to return (default 10, max 25)"),
55
+ });
56
+ /** Input for looking up a single anime or manga by ID or title */
57
+ export const DetailsInputSchema = z
58
+ .object({
59
+ id: z
60
+ .number()
61
+ .int()
62
+ .positive()
63
+ .optional()
64
+ .describe("AniList media ID (e.g. 1 for Cowboy Bebop). Use this if you know the exact ID."),
65
+ title: z
66
+ .string()
67
+ .optional()
68
+ .describe('Search by title if no ID is known (e.g. "Attack on Titan"). Finds the best match.'),
69
+ })
70
+ .refine((data) => data.id !== undefined || data.title !== undefined, {
71
+ message: "Provide either an id or a title to look up.",
72
+ });
73
+ /** Input for fetching a user's anime or manga list */
74
+ export const ListInputSchema = z.object({
75
+ username: usernameSchema
76
+ .optional()
77
+ .describe("AniList username. Falls back to configured default if not provided."),
78
+ type: z
79
+ .enum(["ANIME", "MANGA"])
80
+ .default("ANIME")
81
+ .describe("Get anime or manga list"),
82
+ status: z
83
+ .enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED", "ALL"])
84
+ .default("ALL")
85
+ .describe("Filter by list status. CURRENT = watching/reading now."),
86
+ sort: z
87
+ .enum(["SCORE", "TITLE", "UPDATED", "PROGRESS"])
88
+ .default("UPDATED")
89
+ .describe("How to sort results"),
90
+ limit: z
91
+ .number()
92
+ .int()
93
+ .min(1)
94
+ .max(100)
95
+ .default(25)
96
+ .describe("Maximum entries to return (default 25, max 100)"),
97
+ });
98
+ /** Input for generating a taste profile summary */
99
+ export const TasteInputSchema = z.object({
100
+ username: usernameSchema
101
+ .optional()
102
+ .describe("AniList username. Falls back to configured default if not provided."),
103
+ type: z
104
+ .enum(["ANIME", "MANGA", "BOTH"])
105
+ .default("BOTH")
106
+ .describe("Analyze anime list, manga list, or both"),
107
+ });
108
+ /** Input for personalized recommendations from the user's planning list */
109
+ export const PickInputSchema = z.object({
110
+ username: usernameSchema
111
+ .optional()
112
+ .describe("AniList username. Falls back to configured default if not provided."),
113
+ type: z
114
+ .enum(["ANIME", "MANGA"])
115
+ .default("ANIME")
116
+ .describe("Recommend from anime or manga planning list"),
117
+ mood: z
118
+ .string()
119
+ .optional()
120
+ .describe('Freeform mood or vibe, e.g. "something dark", "chill and wholesome", "hype action"'),
121
+ maxEpisodes: z
122
+ .number()
123
+ .int()
124
+ .positive()
125
+ .optional()
126
+ .describe("Filter out series longer than this episode count"),
127
+ limit: z
128
+ .number()
129
+ .int()
130
+ .min(1)
131
+ .max(15)
132
+ .default(5)
133
+ .describe("Number of recommendations to return (default 5, max 15)"),
134
+ });
135
+ /** Input for comparing taste profiles between two users */
136
+ export const CompareInputSchema = z.object({
137
+ user1: usernameSchema.describe("First AniList username"),
138
+ user2: usernameSchema.describe("Second AniList username"),
139
+ type: z
140
+ .enum(["ANIME", "MANGA"])
141
+ .default("ANIME")
142
+ .describe("Compare anime or manga taste"),
143
+ });
144
+ const MAX_YEAR = new Date().getFullYear() + 2;
145
+ /** Input for browsing anime by season */
146
+ export const SeasonalInputSchema = z.object({
147
+ season: z
148
+ .enum(["WINTER", "SPRING", "SUMMER", "FALL"])
149
+ .optional()
150
+ .describe("Season to browse. Defaults to the current season."),
151
+ year: z
152
+ .number()
153
+ .int()
154
+ .min(1940)
155
+ .max(MAX_YEAR)
156
+ .optional()
157
+ .describe("Year to browse. Defaults to the current year."),
158
+ sort: z
159
+ .enum(["POPULARITY", "SCORE", "TRENDING"])
160
+ .default("POPULARITY")
161
+ .describe("How to rank results"),
162
+ isAdult: z
163
+ .boolean()
164
+ .default(false)
165
+ .describe("Include adult (18+) content in results"),
166
+ limit: z
167
+ .number()
168
+ .int()
169
+ .min(1)
170
+ .max(50)
171
+ .default(15)
172
+ .describe("Number of results to return (default 15, max 50)"),
173
+ });
174
+ /** Input for fetching user statistics */
175
+ export const StatsInputSchema = z.object({
176
+ username: usernameSchema
177
+ .optional()
178
+ .describe("AniList username. Falls back to configured default if not provided."),
179
+ });
180
+ /** Input for year-in-review summary */
181
+ export const WrappedInputSchema = z.object({
182
+ username: usernameSchema
183
+ .optional()
184
+ .describe("AniList username. Falls back to configured default if not provided."),
185
+ year: z
186
+ .number()
187
+ .int()
188
+ .min(2000)
189
+ .max(MAX_YEAR)
190
+ .optional()
191
+ .describe("Year to summarize. Defaults to the current year."),
192
+ type: z
193
+ .enum(["ANIME", "MANGA", "BOTH"])
194
+ .default("BOTH")
195
+ .describe("Summarize anime, manga, or both"),
196
+ });
197
+ /** Input for trending anime/manga */
198
+ export const TrendingInputSchema = z.object({
199
+ type: z
200
+ .enum(["ANIME", "MANGA"])
201
+ .default("ANIME")
202
+ .describe("Show trending anime or manga"),
203
+ isAdult: z
204
+ .boolean()
205
+ .default(false)
206
+ .describe("Include adult (18+) content in results"),
207
+ limit: z
208
+ .number()
209
+ .int()
210
+ .min(1)
211
+ .max(25)
212
+ .default(10)
213
+ .describe("Number of results to return (default 10, max 25)"),
214
+ });
215
+ /** Input for browsing by genre */
216
+ export const GenreBrowseInputSchema = z.object({
217
+ genre: z
218
+ .string()
219
+ .describe('Genre to browse, e.g. "Action", "Romance", "Horror"'),
220
+ type: z
221
+ .enum(["ANIME", "MANGA"])
222
+ .default("ANIME")
223
+ .describe("Browse anime or manga"),
224
+ year: z
225
+ .number()
226
+ .int()
227
+ .min(1940)
228
+ .max(MAX_YEAR)
229
+ .optional()
230
+ .describe("Filter by release year"),
231
+ status: z
232
+ .enum(["FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"])
233
+ .optional()
234
+ .describe("Filter by airing/publishing status"),
235
+ format: z
236
+ .enum([
237
+ "TV",
238
+ "MOVIE",
239
+ "OVA",
240
+ "ONA",
241
+ "SPECIAL",
242
+ "MANGA",
243
+ "NOVEL",
244
+ "ONE_SHOT",
245
+ ])
246
+ .optional()
247
+ .describe("Filter by format"),
248
+ sort: z
249
+ .enum(["SCORE", "POPULARITY", "TRENDING"])
250
+ .default("SCORE")
251
+ .describe("How to rank results"),
252
+ isAdult: z
253
+ .boolean()
254
+ .default(false)
255
+ .describe("Include adult (18+) content in results"),
256
+ limit: z
257
+ .number()
258
+ .int()
259
+ .min(1)
260
+ .max(25)
261
+ .default(10)
262
+ .describe("Number of results to return (default 10, max 25)"),
263
+ });
264
+ /** Input for staff/VA credits lookup */
265
+ export const StaffInputSchema = z
266
+ .object({
267
+ id: z.number().int().positive().optional().describe("AniList media ID"),
268
+ title: z.string().optional().describe("Search by title if no ID is known"),
269
+ })
270
+ .refine((data) => data.id !== undefined || data.title !== undefined, {
271
+ message: "Provide either an id or a title.",
272
+ });
273
+ /** Input for airing schedule lookup */
274
+ export const ScheduleInputSchema = z
275
+ .object({
276
+ id: z
277
+ .number()
278
+ .int()
279
+ .positive()
280
+ .optional()
281
+ .describe("AniList media ID for the anime"),
282
+ title: z.string().optional().describe("Search by title if no ID is known"),
283
+ })
284
+ .refine((data) => data.id !== undefined || data.title !== undefined, {
285
+ message: "Provide either an id or a title.",
286
+ });
287
+ /** Input for character search */
288
+ export const CharacterSearchInputSchema = z.object({
289
+ query: z
290
+ .string()
291
+ .min(1, "Search query cannot be empty")
292
+ .describe('Character name to search for, e.g. "Goku", "Levi Ackerman"'),
293
+ limit: z
294
+ .number()
295
+ .int()
296
+ .min(1)
297
+ .max(10)
298
+ .default(5)
299
+ .describe("Number of results to return (default 5, max 10)"),
300
+ });
301
+ /** Input for community recommendations for a specific title */
302
+ export const RecommendationsInputSchema = z
303
+ .object({
304
+ id: z.number().int().positive().optional().describe("AniList media ID"),
305
+ title: z.string().optional().describe("Search by title if no ID is known"),
306
+ limit: z
307
+ .number()
308
+ .int()
309
+ .min(1)
310
+ .max(25)
311
+ .default(10)
312
+ .describe("Number of recommendations to return (default 10, max 25)"),
313
+ })
314
+ .refine((data) => data.id !== undefined || data.title !== undefined, {
315
+ message: "Provide either an id or a title.",
316
+ });
@@ -0,0 +1,4 @@
1
+ /** Discovery tools: trending and genre browsing without search terms. */
2
+ import type { FastMCP } from "fastmcp";
3
+ /** Register discovery tools on the MCP server */
4
+ export declare function registerDiscoverTools(server: FastMCP): void;
@@ -0,0 +1,94 @@
1
+ /** Discovery tools: trending and genre browsing without search terms. */
2
+ import { anilistClient } from "../api/client.js";
3
+ import { TRENDING_MEDIA_QUERY, GENRE_BROWSE_QUERY } from "../api/queries.js";
4
+ import { TrendingInputSchema, GenreBrowseInputSchema } from "../schemas.js";
5
+ import { formatMediaSummary, throwToolError } from "../utils.js";
6
+ /** Register discovery tools on the MCP server */
7
+ export function registerDiscoverTools(server) {
8
+ // === Trending ===
9
+ server.addTool({
10
+ name: "anilist_trending",
11
+ description: "Show what's trending on AniList right now. " +
12
+ "Use when the user asks what's hot, trending, or generating buzz. " +
13
+ "No search term needed - returns titles ranked by current trending score.",
14
+ parameters: TrendingInputSchema,
15
+ execute: async (args) => {
16
+ try {
17
+ const data = await anilistClient.query(TRENDING_MEDIA_QUERY, {
18
+ type: args.type,
19
+ isAdult: args.isAdult ? undefined : false,
20
+ page: 1,
21
+ perPage: args.limit,
22
+ }, { cache: "search" });
23
+ const results = data.Page.media;
24
+ if (!results.length) {
25
+ return `No trending ${args.type.toLowerCase()} found.`;
26
+ }
27
+ const header = [
28
+ `Trending ${args.type} right now (${data.Page.pageInfo.total} total, showing ${results.length})`,
29
+ "",
30
+ ].join("\n");
31
+ const formatted = results.map((m, i) => `${i + 1}. ${formatMediaSummary(m)}`);
32
+ return header + formatted.join("\n\n");
33
+ }
34
+ catch (error) {
35
+ return throwToolError(error, "fetching trending");
36
+ }
37
+ },
38
+ });
39
+ // === Genre Browse ===
40
+ server.addTool({
41
+ name: "anilist_genres",
42
+ description: "Browse top anime or manga in a specific genre. " +
43
+ "Use when the user asks for the best titles in a genre, " +
44
+ 'e.g. "best romance anime" or "top thriller manga from 2023". ' +
45
+ "No search term needed - discovers by genre with optional year/status/format filters.",
46
+ parameters: GenreBrowseInputSchema,
47
+ execute: async (args) => {
48
+ try {
49
+ const sortMap = {
50
+ SCORE: ["SCORE_DESC"],
51
+ POPULARITY: ["POPULARITY_DESC"],
52
+ TRENDING: ["TRENDING_DESC"],
53
+ };
54
+ const variables = {
55
+ type: args.type,
56
+ genre_in: [args.genre],
57
+ sort: sortMap[args.sort] ?? sortMap.SCORE,
58
+ isAdult: args.isAdult ? undefined : false,
59
+ page: 1,
60
+ perPage: args.limit,
61
+ };
62
+ if (args.year)
63
+ variables.year = args.year;
64
+ if (args.status)
65
+ variables.status = args.status;
66
+ if (args.format)
67
+ variables.format = args.format;
68
+ const data = await anilistClient.query(GENRE_BROWSE_QUERY, variables, { cache: "search" });
69
+ const results = data.Page.media;
70
+ if (!results.length) {
71
+ return `No ${args.type.toLowerCase()} found in genre "${args.genre}".`;
72
+ }
73
+ const filters = [];
74
+ if (args.year)
75
+ filters.push(`${args.year}`);
76
+ if (args.status)
77
+ filters.push(args.status.replace(/_/g, " "));
78
+ if (args.format)
79
+ filters.push(args.format);
80
+ const filterStr = filters.length > 0 ? ` (${filters.join(", ")})` : "";
81
+ const header = [
82
+ `Top ${args.genre} ${args.type}${filterStr}`,
83
+ `${data.Page.pageInfo.total} total, showing ${results.length} by ${args.sort.toLowerCase()}`,
84
+ "",
85
+ ].join("\n");
86
+ const formatted = results.map((m, i) => `${i + 1}. ${formatMediaSummary(m)}`);
87
+ return header + formatted.join("\n\n");
88
+ }
89
+ catch (error) {
90
+ return throwToolError(error, "browsing genres");
91
+ }
92
+ },
93
+ });
94
+ }
@@ -0,0 +1,4 @@
1
+ /** Info tools: staff credits, airing schedule, and character search. */
2
+ import type { FastMCP } from "fastmcp";
3
+ /** Register info tools on the MCP server */
4
+ export declare function registerInfoTools(server: FastMCP): void;
@@ -0,0 +1,172 @@
1
+ /** Info tools: staff credits, airing schedule, and character search. */
2
+ import { anilistClient } from "../api/client.js";
3
+ import { STAFF_QUERY, AIRING_SCHEDULE_QUERY, CHARACTER_SEARCH_QUERY, } from "../api/queries.js";
4
+ import { StaffInputSchema, ScheduleInputSchema, CharacterSearchInputSchema, } from "../schemas.js";
5
+ import { getTitle, throwToolError } from "../utils.js";
6
+ // === Helpers ===
7
+ /** Format seconds until airing as a readable duration */
8
+ function formatTimeUntil(seconds) {
9
+ if (seconds <= 0)
10
+ return "aired";
11
+ const days = Math.floor(seconds / 86400);
12
+ const hours = Math.floor((seconds % 86400) / 3600);
13
+ if (days > 0)
14
+ return `${days}d ${hours}h`;
15
+ const mins = Math.floor((seconds % 3600) / 60);
16
+ return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
17
+ }
18
+ // === Tool Registration ===
19
+ /** Register info tools on the MCP server */
20
+ export function registerInfoTools(server) {
21
+ // === Staff Credits ===
22
+ server.addTool({
23
+ name: "anilist_staff",
24
+ description: "Get staff and voice actor credits for an anime or manga. " +
25
+ "Use when the user asks who directed, wrote, or voiced characters in a title. " +
26
+ "Shows directors, writers, character designers, and Japanese voice actors.",
27
+ parameters: StaffInputSchema,
28
+ execute: async (args) => {
29
+ try {
30
+ const variables = {};
31
+ if (args.id)
32
+ variables.id = args.id;
33
+ if (args.title)
34
+ variables.search = args.title;
35
+ const data = await anilistClient.query(STAFF_QUERY, variables, { cache: "media" });
36
+ const m = data.Media;
37
+ const lines = [
38
+ `# Staff: ${getTitle(m.title)}`,
39
+ `Format: ${m.format ?? "Unknown"}`,
40
+ "",
41
+ ];
42
+ // Staff roles (director, writer, etc.)
43
+ if (m.staff.edges.length > 0) {
44
+ lines.push("## Production Staff");
45
+ for (const edge of m.staff.edges) {
46
+ const name = edge.node.name.full;
47
+ const native = edge.node.name.native
48
+ ? ` (${edge.node.name.native})`
49
+ : "";
50
+ lines.push(` ${edge.role}: ${name}${native}`);
51
+ }
52
+ lines.push("");
53
+ }
54
+ // Characters with voice actors
55
+ if (m.characters.edges.length > 0) {
56
+ lines.push("## Characters & Voice Actors");
57
+ for (const edge of m.characters.edges) {
58
+ const charName = edge.node.name.full;
59
+ const role = edge.role;
60
+ const va = edge.voiceActors[0];
61
+ const vaStr = va ? ` - VA: ${va.name.full}` : "";
62
+ lines.push(` ${charName} (${role})${vaStr}`);
63
+ }
64
+ lines.push("");
65
+ }
66
+ lines.push(`AniList: ${m.siteUrl}`);
67
+ return lines.join("\n");
68
+ }
69
+ catch (error) {
70
+ return throwToolError(error, "fetching staff");
71
+ }
72
+ },
73
+ });
74
+ // === Airing Schedule ===
75
+ server.addTool({
76
+ name: "anilist_schedule",
77
+ description: "Get the airing schedule for an anime. " +
78
+ "Use when the user asks when the next episode airs, " +
79
+ "or wants to see upcoming episode dates for a currently airing show.",
80
+ parameters: ScheduleInputSchema,
81
+ execute: async (args) => {
82
+ try {
83
+ const variables = { notYetAired: true };
84
+ if (args.id)
85
+ variables.id = args.id;
86
+ if (args.title)
87
+ variables.search = args.title;
88
+ const data = await anilistClient.query(AIRING_SCHEDULE_QUERY, variables, { cache: "search" });
89
+ const m = data.Media;
90
+ const lines = [
91
+ `# Schedule: ${getTitle(m.title)}`,
92
+ `Status: ${m.status?.replace(/_/g, " ") ?? "Unknown"}`,
93
+ ];
94
+ if (m.episodes)
95
+ lines.push(`Episodes: ${m.episodes}`);
96
+ // Next episode
97
+ if (m.nextAiringEpisode) {
98
+ const next = m.nextAiringEpisode;
99
+ const date = new Date(next.airingAt * 1000);
100
+ lines.push("");
101
+ lines.push(`Next Episode: ${next.episode}`);
102
+ lines.push(`Airs: ${date.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" })} ` +
103
+ `at ${date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`);
104
+ lines.push(`In: ${formatTimeUntil(next.timeUntilAiring)}`);
105
+ }
106
+ else {
107
+ lines.push("", "No upcoming episodes scheduled.");
108
+ }
109
+ // Upcoming episodes
110
+ const upcoming = m.airingSchedule.nodes.filter((n) => n.timeUntilAiring > 0);
111
+ if (upcoming.length > 1) {
112
+ lines.push("", "Upcoming:");
113
+ for (const ep of upcoming.slice(0, 8)) {
114
+ const date = new Date(ep.airingAt * 1000);
115
+ const dateStr = date.toLocaleDateString("en-US", {
116
+ month: "short",
117
+ day: "numeric",
118
+ });
119
+ lines.push(` Ep ${ep.episode}: ${dateStr} (${formatTimeUntil(ep.timeUntilAiring)})`);
120
+ }
121
+ }
122
+ lines.push("", `AniList: ${m.siteUrl}`);
123
+ return lines.join("\n");
124
+ }
125
+ catch (error) {
126
+ return throwToolError(error, "fetching schedule");
127
+ }
128
+ },
129
+ });
130
+ // === Character Search ===
131
+ server.addTool({
132
+ name: "anilist_characters",
133
+ description: "Search for anime/manga characters by name. " +
134
+ "Use when the user asks about a specific character, wants to know " +
135
+ "which series a character appears in, or who voices them.",
136
+ parameters: CharacterSearchInputSchema,
137
+ execute: async (args) => {
138
+ try {
139
+ const data = await anilistClient.query(CHARACTER_SEARCH_QUERY, { search: args.query, page: 1, perPage: args.limit }, { cache: "search" });
140
+ const results = data.Page.characters;
141
+ if (!results.length) {
142
+ return `No characters found matching "${args.query}".`;
143
+ }
144
+ const lines = [
145
+ `Found ${data.Page.pageInfo.total} character(s) matching "${args.query}"`,
146
+ "",
147
+ ];
148
+ for (let i = 0; i < results.length; i++) {
149
+ const char = results[i];
150
+ const native = char.name.native ? ` (${char.name.native})` : "";
151
+ const favs = char.favourites > 0
152
+ ? ` - ${char.favourites.toLocaleString()} favorites`
153
+ : "";
154
+ lines.push(`${i + 1}. ${char.name.full}${native}${favs}`);
155
+ // Appearances
156
+ for (const edge of char.media.edges.slice(0, 3)) {
157
+ const mediaTitle = edge.node.title.english || edge.node.title.romaji || "?";
158
+ const va = edge.voiceActors[0];
159
+ const vaStr = va ? ` (VA: ${va.name.full})` : "";
160
+ lines.push(` ${edge.characterRole}: ${mediaTitle} (${edge.node.format ?? edge.node.type})${vaStr}`);
161
+ }
162
+ lines.push(` URL: ${char.siteUrl}`);
163
+ lines.push("");
164
+ }
165
+ return lines.join("\n");
166
+ }
167
+ catch (error) {
168
+ return throwToolError(error, "searching characters");
169
+ }
170
+ },
171
+ });
172
+ }
@@ -0,0 +1,4 @@
1
+ /** User list tools: fetch and display a user's anime/manga list. */
2
+ import type { FastMCP } from "fastmcp";
3
+ /** Register user list tools on the MCP server */
4
+ export declare function registerListTools(server: FastMCP): void;