ani-mcp 0.5.1 → 0.6.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
@@ -126,6 +126,30 @@ Works with any MCP-compatible client.
126
126
  | `anilist_rate` | Score a title (0-10) |
127
127
  | `anilist_delete_from_list` | Remove an entry from your list |
128
128
 
129
+ ## Resources
130
+
131
+ MCP resources provide context to your AI assistant without needing a tool call. Clients that support resources can automatically include this information in conversations.
132
+
133
+ | Resource | Description |
134
+ | --- | --- |
135
+ | `anilist://profile` | Your AniList profile with bio, stats, and favourites |
136
+ | `anilist://taste/{type}` | Taste profile (genre weights, themes, scoring patterns) for ANIME or MANGA |
137
+ | `anilist://list/{type}` | Currently watching/reading entries with progress and scores |
138
+
139
+ ## Prompts
140
+
141
+ Pre-built conversation starters that clients can offer as quick actions.
142
+
143
+ | Prompt | Description |
144
+ | --- | --- |
145
+ | `seasonal_review` | Review this season's anime against your taste profile |
146
+ | `what_to_watch` | Plan what to watch now with optional mood and time budget |
147
+ | `roast_my_taste` | Get a humorous roast of your anime taste |
148
+ | `compare_us` | Compare your taste with another user |
149
+ | `year_in_review` | Your anime/manga year in review |
150
+ | `explain_title` | Why would you like (or dislike) a specific title? |
151
+ | `find_similar` | Find titles similar to one you enjoyed |
152
+
129
153
  ## Examples
130
154
 
131
155
  Here are some things you can ask your AI assistant once ani-mcp is connected:
package/dist/index.js CHANGED
@@ -9,6 +9,8 @@ import { registerDiscoverTools } from "./tools/discover.js";
9
9
  import { registerInfoTools } from "./tools/info.js";
10
10
  import { registerWriteTools } from "./tools/write.js";
11
11
  import { registerSocialTools } from "./tools/social.js";
12
+ import { registerResources } from "./resources.js";
13
+ import { registerPrompts } from "./prompts.js";
12
14
  // Both vars are optional - warn on missing so operators know what's available
13
15
  if (!process.env.ANILIST_USERNAME) {
14
16
  console.warn("ANILIST_USERNAME not set - tools will require a username argument.");
@@ -18,7 +20,7 @@ if (!process.env.ANILIST_TOKEN) {
18
20
  }
19
21
  const server = new FastMCP({
20
22
  name: "ani-mcp",
21
- version: "0.5.1",
23
+ version: "0.6.0",
22
24
  });
23
25
  registerSearchTools(server);
24
26
  registerListTools(server);
@@ -27,6 +29,8 @@ registerDiscoverTools(server);
27
29
  registerInfoTools(server);
28
30
  registerWriteTools(server);
29
31
  registerSocialTools(server);
32
+ registerResources(server);
33
+ registerPrompts(server);
30
34
  // === Transport ===
31
35
  const transport = process.env.MCP_TRANSPORT === "http" ? "httpStream" : "stdio";
32
36
  if (transport === "httpStream") {
@@ -0,0 +1,4 @@
1
+ /** MCP Prompts: pre-built conversation starters */
2
+ import type { FastMCP } from "fastmcp";
3
+ /** Register MCP prompts on the server */
4
+ export declare function registerPrompts(server: FastMCP): void;
@@ -0,0 +1,205 @@
1
+ /** MCP Prompts: pre-built conversation starters */
2
+ import { resolveSeasonYear } from "./utils.js";
3
+ /** Register MCP prompts on the server */
4
+ export function registerPrompts(server) {
5
+ // === Seasonal Review ===
6
+ server.addPrompt({
7
+ name: "seasonal_review",
8
+ description: "Review this season's anime lineup against my taste profile.",
9
+ arguments: [
10
+ {
11
+ name: "season",
12
+ description: "Season to review: WINTER, SPRING, SUMMER, or FALL. Defaults to current.",
13
+ required: false,
14
+ },
15
+ {
16
+ name: "year",
17
+ description: "Year to review. Defaults to current year.",
18
+ required: false,
19
+ },
20
+ ],
21
+ async load({ season, year }) {
22
+ const resolved = resolveSeasonYear(season, year ? Number(year) : undefined);
23
+ return {
24
+ messages: [
25
+ {
26
+ role: "user",
27
+ content: {
28
+ type: "text",
29
+ text: `Use anilist_pick with source SEASONAL to review the ${resolved.season} ${resolved.year} anime season against my taste profile. ` +
30
+ `Show the top picks and explain why each one matches my preferences.`,
31
+ },
32
+ },
33
+ ],
34
+ };
35
+ },
36
+ });
37
+ // === What To Watch ===
38
+ server.addPrompt({
39
+ name: "what_to_watch",
40
+ description: "Plan what to watch right now from my current list.",
41
+ arguments: [
42
+ {
43
+ name: "mood",
44
+ description: "Mood filter: dark, chill, hype, action, romantic, funny, brainy, sad, etc.",
45
+ required: false,
46
+ },
47
+ {
48
+ name: "minutes",
49
+ description: "Time budget in minutes. Defaults to 90.",
50
+ required: false,
51
+ },
52
+ ],
53
+ async load({ mood, minutes }) {
54
+ const budget = minutes ? Number(minutes) : 90;
55
+ const moodClause = mood ? ` in a ${mood} mood` : "";
56
+ return {
57
+ messages: [
58
+ {
59
+ role: "user",
60
+ content: {
61
+ type: "text",
62
+ text: `I have ${budget} minutes${moodClause}. ` +
63
+ `Use anilist_session to plan what I should watch from my current list.`,
64
+ },
65
+ },
66
+ ],
67
+ };
68
+ },
69
+ });
70
+ // === Roast My Taste ===
71
+ server.addPrompt({
72
+ name: "roast_my_taste",
73
+ description: "Get a humorous roast of your anime taste.",
74
+ arguments: [
75
+ {
76
+ name: "username",
77
+ description: "Username to roast. Defaults to configured user.",
78
+ required: false,
79
+ },
80
+ ],
81
+ async load({ username }) {
82
+ const target = username ?? "my";
83
+ const userClause = username
84
+ ? `Use anilist_taste for ${username}`
85
+ : "Use anilist_taste for me";
86
+ return {
87
+ messages: [
88
+ {
89
+ role: "user",
90
+ content: {
91
+ type: "text",
92
+ text: `${userClause} and then roast ${target} anime taste. ` +
93
+ `Be funny and specific about genre preferences and scoring patterns.`,
94
+ },
95
+ },
96
+ ],
97
+ };
98
+ },
99
+ });
100
+ // === Compare Us ===
101
+ server.addPrompt({
102
+ name: "compare_us",
103
+ description: "Compare my taste with another user.",
104
+ arguments: [
105
+ {
106
+ name: "other_username",
107
+ description: "The other user to compare with.",
108
+ required: true,
109
+ },
110
+ ],
111
+ async load({ other_username }) {
112
+ return {
113
+ messages: [
114
+ {
115
+ role: "user",
116
+ content: {
117
+ type: "text",
118
+ text: `Use anilist_compare to compare my taste with ${other_username}. ` +
119
+ `Highlight the biggest taste differences and shared favourites.`,
120
+ },
121
+ },
122
+ ],
123
+ };
124
+ },
125
+ });
126
+ // === Year In Review ===
127
+ server.addPrompt({
128
+ name: "year_in_review",
129
+ description: "Get your anime/manga year in review wrapped summary.",
130
+ arguments: [
131
+ {
132
+ name: "year",
133
+ description: "Year to review. Defaults to current year.",
134
+ required: false,
135
+ },
136
+ ],
137
+ async load({ year }) {
138
+ const y = year ?? new Date().getFullYear().toString();
139
+ return {
140
+ messages: [
141
+ {
142
+ role: "user",
143
+ content: {
144
+ type: "text",
145
+ text: `Use anilist_wrapped to generate my ${y} anime and manga year in review. ` +
146
+ `Summarize the highlights and interesting patterns.`,
147
+ },
148
+ },
149
+ ],
150
+ };
151
+ },
152
+ });
153
+ // === Explain Title ===
154
+ server.addPrompt({
155
+ name: "explain_title",
156
+ description: "Explain why you would or wouldn't like a specific title.",
157
+ arguments: [
158
+ {
159
+ name: "title",
160
+ description: "The anime or manga title to explain.",
161
+ required: true,
162
+ },
163
+ ],
164
+ async load({ title }) {
165
+ return {
166
+ messages: [
167
+ {
168
+ role: "user",
169
+ content: {
170
+ type: "text",
171
+ text: `Use anilist_explain to analyze why I would or wouldn't like "${title}". ` +
172
+ `Break down genre affinity, theme alignment, and how it compares to titles I've enjoyed.`,
173
+ },
174
+ },
175
+ ],
176
+ };
177
+ },
178
+ });
179
+ // === Find Similar ===
180
+ server.addPrompt({
181
+ name: "find_similar",
182
+ description: "Find titles similar to one you enjoyed.",
183
+ arguments: [
184
+ {
185
+ name: "title",
186
+ description: "The anime or manga to find similar titles for.",
187
+ required: true,
188
+ },
189
+ ],
190
+ async load({ title }) {
191
+ return {
192
+ messages: [
193
+ {
194
+ role: "user",
195
+ content: {
196
+ type: "text",
197
+ text: `Use anilist_similar to find titles similar to "${title}". ` +
198
+ `Explain what makes each recommendation similar and whether it's on my list already.`,
199
+ },
200
+ },
201
+ ],
202
+ };
203
+ },
204
+ });
205
+ }
@@ -0,0 +1,4 @@
1
+ /** MCP Resources: expose user context without tool calls */
2
+ import type { FastMCP } from "fastmcp";
3
+ /** Register MCP resources on the server */
4
+ export declare function registerResources(server: FastMCP): void;
@@ -0,0 +1,111 @@
1
+ /** MCP Resources: expose user context without tool calls */
2
+ import { anilistClient } from "./api/client.js";
3
+ import { USER_PROFILE_QUERY, USER_STATS_QUERY } from "./api/queries.js";
4
+ import { buildTasteProfile, describeTasteProfile, } from "./engine/taste.js";
5
+ import { formatProfile } from "./tools/social.js";
6
+ import { formatListEntry } from "./tools/lists.js";
7
+ import { getDefaultUsername, detectScoreFormat } from "./utils.js";
8
+ /** Register MCP resources on the server */
9
+ export function registerResources(server) {
10
+ // === User Profile ===
11
+ server.addResource({
12
+ uri: "anilist://profile",
13
+ name: "User Profile",
14
+ description: "AniList profile with bio, anime/manga stats, and favourites.",
15
+ mimeType: "text/plain",
16
+ async load() {
17
+ const username = getDefaultUsername();
18
+ const data = await anilistClient.query(USER_PROFILE_QUERY, { name: username }, { cache: "stats" });
19
+ return { text: formatProfile(data.User) };
20
+ },
21
+ });
22
+ // === Taste Profile ===
23
+ server.addResourceTemplate({
24
+ uriTemplate: "anilist://taste/{type}",
25
+ name: "Taste Profile",
26
+ description: "Genre weights, top themes, scoring patterns, and format split derived from completed list.",
27
+ mimeType: "text/plain",
28
+ arguments: [
29
+ {
30
+ name: "type",
31
+ description: "ANIME or MANGA",
32
+ required: true,
33
+ },
34
+ ],
35
+ async load({ type }) {
36
+ const username = getDefaultUsername();
37
+ const mediaType = String(type).toUpperCase();
38
+ const entries = await anilistClient.fetchList(username, mediaType, "COMPLETED");
39
+ const profile = buildTasteProfile(entries);
40
+ return { text: formatTasteProfile(profile, username) };
41
+ },
42
+ });
43
+ // === Current List ===
44
+ server.addResourceTemplate({
45
+ uriTemplate: "anilist://list/{type}",
46
+ name: "Current List",
47
+ description: "Currently watching anime or reading manga entries with progress and scores.",
48
+ mimeType: "text/plain",
49
+ arguments: [
50
+ {
51
+ name: "type",
52
+ description: "ANIME or MANGA",
53
+ required: true,
54
+ },
55
+ ],
56
+ async load({ type }) {
57
+ const username = getDefaultUsername();
58
+ const mediaType = String(type).toUpperCase();
59
+ const [entries, scoreFormat] = await Promise.all([
60
+ anilistClient.fetchList(username, mediaType, "CURRENT"),
61
+ detectScoreFormat(async () => {
62
+ const data = await anilistClient.query(USER_STATS_QUERY, { name: username }, { cache: "stats" });
63
+ return data.User.mediaListOptions.scoreFormat;
64
+ }),
65
+ ]);
66
+ if (!entries.length) {
67
+ return {
68
+ text: `${username} has no current ${mediaType.toLowerCase()} entries.`,
69
+ };
70
+ }
71
+ const header = `${username}'s current ${mediaType.toLowerCase()} - ${entries.length} entries`;
72
+ const formatted = entries.map((entry, i) => formatListEntry(entry, i + 1, scoreFormat));
73
+ return { text: [header, "", ...formatted].join("\n\n") };
74
+ },
75
+ });
76
+ }
77
+ // === Formatting Helpers ===
78
+ /** Format a taste profile with detailed breakdowns */
79
+ function formatTasteProfile(profile, username) {
80
+ const lines = [
81
+ `# Taste Profile: ${username}`,
82
+ "",
83
+ describeTasteProfile(profile, username),
84
+ ];
85
+ // Detailed genre breakdown
86
+ if (profile.genres.length > 0) {
87
+ lines.push("", "Genre Weights (higher = stronger preference):");
88
+ for (const g of profile.genres.slice(0, 10)) {
89
+ lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
90
+ }
91
+ }
92
+ // Detailed tag breakdown
93
+ if (profile.tags.length > 0) {
94
+ lines.push("", "Top Themes:");
95
+ for (const t of profile.tags.slice(0, 10)) {
96
+ lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
97
+ }
98
+ }
99
+ // Score distribution
100
+ if (profile.scoring.totalScored > 0) {
101
+ lines.push("", "Score Distribution:");
102
+ for (let s = 10; s >= 1; s--) {
103
+ const count = profile.scoring.distribution[s] ?? 0;
104
+ if (count > 0) {
105
+ const bar = "#".repeat(Math.min(count, 30));
106
+ lines.push(` ${s}/10: ${bar} (${count})`);
107
+ }
108
+ }
109
+ }
110
+ return lines.join("\n");
111
+ }
@@ -1,4 +1,7 @@
1
1
  /** User list tools: fetch and display a user's anime/manga list. */
2
2
  import type { FastMCP } from "fastmcp";
3
+ import type { AniListMediaListEntry, ScoreFormat } from "../types.js";
3
4
  /** Register user list tools on the MCP server */
4
5
  export declare function registerListTools(server: FastMCP): void;
6
+ /** Format a single list entry with title, progress, score, and update date */
7
+ export declare function formatListEntry(entry: AniListMediaListEntry, index: number, scoreFmt: ScoreFormat): string;
@@ -207,7 +207,7 @@ function formatTypeStats(stats, label) {
207
207
  return lines;
208
208
  }
209
209
  /** Format a single list entry with title, progress, score, and update date */
210
- function formatListEntry(entry, index, scoreFmt) {
210
+ export function formatListEntry(entry, index, scoreFmt) {
211
211
  const media = entry.media;
212
212
  const title = getTitle(media.title);
213
213
  const format = media.format ?? "?";
@@ -1,4 +1,7 @@
1
1
  /** Social tools: activity feed, user profiles, and community reviews. */
2
2
  import type { FastMCP } from "fastmcp";
3
+ import type { UserProfileResponse } from "../types.js";
3
4
  /** Register social and community tools */
4
5
  export declare function registerSocialTools(server: FastMCP): void;
6
+ /** Format a user profile as text */
7
+ export declare function formatProfile(user: UserProfileResponse["User"]): string;
@@ -62,49 +62,7 @@ export function registerSocialTools(server) {
62
62
  try {
63
63
  const username = getDefaultUsername(args.username);
64
64
  const data = await anilistClient.query(USER_PROFILE_QUERY, { name: username }, { cache: "stats" });
65
- const user = data.User;
66
- const lines = [`# ${user.name}`, user.siteUrl, ""];
67
- // About/bio
68
- if (user.about) {
69
- lines.push(truncateDescription(user.about, 500), "");
70
- }
71
- // Anime stats
72
- const a = user.statistics.anime;
73
- if (a.count > 0) {
74
- const days = (a.minutesWatched / 1440).toFixed(1);
75
- lines.push(`## Anime: ${a.count} titles | ${a.episodesWatched} episodes | ${days} days | Mean ${a.meanScore.toFixed(1)}`);
76
- }
77
- // Manga stats
78
- const m = user.statistics.manga;
79
- if (m.count > 0) {
80
- lines.push(`## Manga: ${m.count} titles | ${m.chaptersRead} chapters | ${m.volumesRead} volumes | Mean ${m.meanScore.toFixed(1)}`);
81
- }
82
- // Favorites
83
- const fav = user.favourites;
84
- if (fav.anime.nodes.length) {
85
- lines.push("", "Favourite Anime: " +
86
- fav.anime.nodes.map((n) => getTitle(n.title)).join(", "));
87
- }
88
- if (fav.manga.nodes.length) {
89
- lines.push("Favourite Manga: " +
90
- fav.manga.nodes.map((n) => getTitle(n.title)).join(", "));
91
- }
92
- if (fav.characters.nodes.length) {
93
- lines.push("Favourite Characters: " +
94
- fav.characters.nodes.map((n) => n.name.full).join(", "));
95
- }
96
- if (fav.staff.nodes.length) {
97
- lines.push("Favourite Staff: " +
98
- fav.staff.nodes.map((n) => n.name.full).join(", "));
99
- }
100
- if (fav.studios.nodes.length) {
101
- lines.push("Favourite Studios: " +
102
- fav.studios.nodes.map((n) => n.name).join(", "));
103
- }
104
- // Account age
105
- const created = new Date(user.createdAt * 1000).toLocaleDateString("en-US", { month: "short", year: "numeric" });
106
- lines.push("", `Member since ${created}`);
107
- return lines.join("\n");
65
+ return formatProfile(data.User);
108
66
  }
109
67
  catch (error) {
110
68
  return throwToolError(error, "fetching profile");
@@ -178,6 +136,51 @@ export function registerSocialTools(server) {
178
136
  });
179
137
  }
180
138
  // === Formatting Helpers ===
139
+ /** Format a user profile as text */
140
+ export function formatProfile(user) {
141
+ const lines = [`# ${user.name}`, user.siteUrl, ""];
142
+ // About/bio
143
+ if (user.about) {
144
+ lines.push(truncateDescription(user.about, 500), "");
145
+ }
146
+ // Anime stats
147
+ const a = user.statistics.anime;
148
+ if (a.count > 0) {
149
+ const days = (a.minutesWatched / 1440).toFixed(1);
150
+ lines.push(`## Anime: ${a.count} titles | ${a.episodesWatched} episodes | ${days} days | Mean ${a.meanScore.toFixed(1)}`);
151
+ }
152
+ // Manga stats
153
+ const m = user.statistics.manga;
154
+ if (m.count > 0) {
155
+ lines.push(`## Manga: ${m.count} titles | ${m.chaptersRead} chapters | ${m.volumesRead} volumes | Mean ${m.meanScore.toFixed(1)}`);
156
+ }
157
+ // Favourites
158
+ const fav = user.favourites;
159
+ if (fav.anime.nodes.length) {
160
+ lines.push("", "Favourite Anime: " +
161
+ fav.anime.nodes.map((n) => getTitle(n.title)).join(", "));
162
+ }
163
+ if (fav.manga.nodes.length) {
164
+ lines.push("Favourite Manga: " +
165
+ fav.manga.nodes.map((n) => getTitle(n.title)).join(", "));
166
+ }
167
+ if (fav.characters.nodes.length) {
168
+ lines.push("Favourite Characters: " +
169
+ fav.characters.nodes.map((n) => n.name.full).join(", "));
170
+ }
171
+ if (fav.staff.nodes.length) {
172
+ lines.push("Favourite Staff: " +
173
+ fav.staff.nodes.map((n) => n.name.full).join(", "));
174
+ }
175
+ if (fav.studios.nodes.length) {
176
+ lines.push("Favourite Studios: " +
177
+ fav.studios.nodes.map((n) => n.name).join(", "));
178
+ }
179
+ // Account age
180
+ const created = new Date(user.createdAt * 1000).toLocaleDateString("en-US", { month: "short", year: "numeric" });
181
+ lines.push("", `Member since ${created}`);
182
+ return lines.join("\n");
183
+ }
181
184
  /** Format a single activity entry */
182
185
  function formatActivity(activity, index) {
183
186
  const date = new Date(activity.createdAt * 1000).toLocaleDateString("en-US", {
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ani-mcp",
4
- "version": "0.5.1",
4
+ "version": "0.6.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": {
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.5.1",
4
+ "version": "0.6.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.5.1",
9
+ "version": "0.6.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ani-mcp",
14
- "version": "0.5.1",
14
+ "version": "0.6.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },