ani-mcp 0.8.2 → 0.8.4

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.
@@ -28,3 +28,5 @@ export interface TasteProfile {
28
28
  export declare function buildTasteProfile(entries: AniListMediaListEntry[]): TasteProfile;
29
29
  /** Summarize a taste profile as natural language */
30
30
  export declare function describeTasteProfile(profile: TasteProfile, username: string): string;
31
+ /** Detailed profile breakdown: genre weights, top themes, score distribution */
32
+ export declare function formatTasteProfileText(profile: TasteProfile): string[];
@@ -177,6 +177,37 @@ function computeDecay(entry) {
177
177
  const yearsSince = (now - epoch) / (365.25 * 24 * 3600);
178
178
  return Math.exp(-DECAY_LAMBDA * Math.max(0, yearsSince));
179
179
  }
180
+ /** Detailed profile breakdown: genre weights, top themes, score distribution */
181
+ export function formatTasteProfileText(profile) {
182
+ const lines = [];
183
+ // Detailed genre breakdown
184
+ if (profile.genres.length > 0) {
185
+ lines.push("", "Genre Weights (higher = stronger preference):");
186
+ for (const g of profile.genres.slice(0, 10)) {
187
+ lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
188
+ }
189
+ }
190
+ // Detailed tag breakdown
191
+ if (profile.tags.length > 0) {
192
+ lines.push("", "Top Themes:");
193
+ for (const t of profile.tags.slice(0, 10)) {
194
+ lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
195
+ }
196
+ }
197
+ // Score distribution bar chart
198
+ if (profile.scoring.totalScored > 0) {
199
+ lines.push("", "Score Distribution:");
200
+ for (let s = 10; s >= 1; s--) {
201
+ const count = profile.scoring.distribution[s] ?? 0;
202
+ if (count > 0) {
203
+ // Cap at 30 chars
204
+ const bar = "#".repeat(Math.min(count, 30));
205
+ lines.push(` ${s}/10: ${bar} (${count})`);
206
+ }
207
+ }
208
+ }
209
+ return lines;
210
+ }
180
211
  /** Empty profile for users with too few scored entries */
181
212
  function emptyProfile(totalCompleted) {
182
213
  return {
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ if (!process.env.ANILIST_TOKEN) {
21
21
  }
22
22
  const server = new FastMCP({
23
23
  name: "ani-mcp",
24
- version: "0.8.2",
24
+ version: "0.8.4",
25
25
  });
26
26
  registerSearchTools(server);
27
27
  registerListTools(server);
package/dist/resources.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** MCP Resources: expose user context without tool calls */
2
2
  import { anilistClient } from "./api/client.js";
3
3
  import { USER_PROFILE_QUERY } from "./api/queries.js";
4
- import { buildTasteProfile, describeTasteProfile, } from "./engine/taste.js";
4
+ import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "./engine/taste.js";
5
5
  import { formatProfile } from "./tools/social.js";
6
6
  import { formatListEntry } from "./tools/lists.js";
7
7
  import { getDefaultUsername, getScoreFormat } from "./utils.js";
@@ -99,31 +99,7 @@ function formatTasteProfile(profile, username) {
99
99
  `# Taste Profile: ${username}`,
100
100
  "",
101
101
  describeTasteProfile(profile, username),
102
+ ...formatTasteProfileText(profile),
102
103
  ];
103
- // Detailed genre breakdown
104
- if (profile.genres.length > 0) {
105
- lines.push("", "Genre Weights (higher = stronger preference):");
106
- for (const g of profile.genres.slice(0, 10)) {
107
- lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
108
- }
109
- }
110
- // Detailed tag breakdown
111
- if (profile.tags.length > 0) {
112
- lines.push("", "Top Themes:");
113
- for (const t of profile.tags.slice(0, 10)) {
114
- lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
115
- }
116
- }
117
- // Score distribution
118
- if (profile.scoring.totalScored > 0) {
119
- lines.push("", "Score Distribution:");
120
- for (let s = 10; s >= 1; s--) {
121
- const count = profile.scoring.distribution[s] ?? 0;
122
- if (count > 0) {
123
- const bar = "#".repeat(Math.min(count, 30));
124
- lines.push(` ${s}/10: ${bar} (${count})`);
125
- }
126
- }
127
- }
128
104
  return lines.join("\n");
129
105
  }
package/dist/schemas.js CHANGED
@@ -468,7 +468,7 @@ export const RateInputSchema = z.object({
468
468
  .number()
469
469
  .min(0)
470
470
  .max(10)
471
- .describe("Score on a 0-10 scale. Use 0 to remove a score."),
471
+ .describe("Score on a 0-10 scale (decimals like 7.5 are supported). Use 0 to remove a score."),
472
472
  });
473
473
  /** Input for removing a title from the list */
474
474
  export const DeleteFromListInputSchema = z
@@ -498,10 +498,7 @@ export const ExplainInputSchema = z
498
498
  .positive()
499
499
  .optional()
500
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"),
501
+ title: z.string().optional().describe("Search by title if no ID is known"),
505
502
  username: usernameSchema
506
503
  .optional()
507
504
  .describe("AniList username. Falls back to configured default if not provided."),
@@ -526,10 +523,7 @@ export const SimilarInputSchema = z
526
523
  .positive()
527
524
  .optional()
528
525
  .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"),
526
+ title: z.string().optional().describe("Search by title if no ID is known"),
533
527
  limit: z
534
528
  .number()
535
529
  .int()
@@ -4,7 +4,7 @@ import { BATCH_RELATIONS_QUERY, DISCOVER_MEDIA_QUERY, MEDIA_DETAILS_QUERY, RECOM
4
4
  import { TasteInputSchema, PickInputSchema, SessionInputSchema, SequelAlertInputSchema, WatchOrderInputSchema, CompareInputSchema, WrappedInputSchema, ExplainInputSchema, SimilarInputSchema, } from "../schemas.js";
5
5
  import { getTitle, getDefaultUsername, throwToolError, isNsfwEnabled, resolveSeasonYear, resolveAlias, } from "../utils.js";
6
6
  import { SEARCH_MEDIA_QUERY } from "../api/queries.js";
7
- import { buildTasteProfile, describeTasteProfile, } from "../engine/taste.js";
7
+ import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "../engine/taste.js";
8
8
  import { matchCandidates, explainMatch } from "../engine/matcher.js";
9
9
  import { parseMood, hasMoodMatch, seasonalMoodSuggestions, } from "../engine/mood.js";
10
10
  import { computeCompatibility, computeGenreDivergences, findCrossRecs, } from "../engine/compare.js";
@@ -80,33 +80,8 @@ export function registerRecommendTools(server) {
80
80
  `# Taste Profile: ${username}`,
81
81
  "",
82
82
  describeTasteProfile(profile, username),
83
+ ...formatTasteProfileText(profile),
83
84
  ];
84
- // Detailed genre breakdown
85
- if (profile.genres.length > 0) {
86
- lines.push("", "Genre Weights (higher = stronger preference):");
87
- for (const g of profile.genres.slice(0, 10)) {
88
- lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
89
- }
90
- }
91
- // Detailed tag breakdown
92
- if (profile.tags.length > 0) {
93
- lines.push("", "Top Themes:");
94
- for (const t of profile.tags.slice(0, 10)) {
95
- lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
96
- }
97
- }
98
- // Score distribution bar chart
99
- if (profile.scoring.totalScored > 0) {
100
- lines.push("", "Score Distribution:");
101
- for (let s = 10; s >= 1; s--) {
102
- const count = profile.scoring.distribution[s] ?? 0;
103
- if (count > 0) {
104
- // Cap at 30 chars
105
- const bar = "#".repeat(Math.min(count, 30));
106
- lines.push(` ${s}/10: ${bar} (${count})`);
107
- }
108
- }
109
- }
110
85
  return lines.join("\n");
111
86
  }
112
87
  catch (error) {
@@ -378,7 +378,9 @@ export function registerWriteTools(server) {
378
378
  requireAuth();
379
379
  const variables = { [FAVOURITE_VAR_MAP[args.type]]: args.id };
380
380
  const data = await anilistClient.query(TOGGLE_FAVOURITE_MUTATION, variables, { cache: null });
381
- anilistClient.invalidateUser(await getViewerName());
381
+ const viewerName = await getViewerName();
382
+ anilistClient.invalidateUser(viewerName);
383
+ invalidateUserProfiles(viewerName);
382
384
  // Check if entity is now in favourites (added) or absent (removed)
383
385
  const field = FAVOURITE_FIELD_MAP[args.type];
384
386
  const isFavourited = data.ToggleFavourite[field].nodes.some((n) => n.id === args.id);
@@ -410,7 +412,9 @@ export function registerWriteTools(server) {
410
412
  try {
411
413
  requireAuth();
412
414
  const data = await anilistClient.query(SAVE_TEXT_ACTIVITY_MUTATION, { text: args.text }, { cache: null });
413
- anilistClient.invalidateUser(await getViewerName());
415
+ const viewerName = await getViewerName();
416
+ anilistClient.invalidateUser(viewerName);
417
+ invalidateUserProfiles(viewerName);
414
418
  const activity = data.SaveTextActivity;
415
419
  const dateStr = new Date(activity.createdAt * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
416
420
  return [
package/dist/utils.js CHANGED
@@ -60,7 +60,13 @@ export function truncateDescription(text, maxLength = 500) {
60
60
  if (!text)
61
61
  return "No description available.";
62
62
  // AniList descriptions can contain HTML even with asHtml: false
63
- const clean = text.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "");
63
+ let clean = text.replace(/<br\s*\/?>/gi, "\n");
64
+ // Loop to handle nested fragments like <scr<script>ipt>
65
+ let prev = "";
66
+ while (prev !== clean) {
67
+ prev = clean;
68
+ clean = clean.replace(/<[^>]+>/g, "");
69
+ }
64
70
  if (clean.length <= maxLength)
65
71
  return clean;
66
72
  const truncated = clean.slice(0, maxLength);
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ani-mcp",
4
- "version": "0.8.2",
4
+ "version": "0.8.4",
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.8.2",
4
+ "version": "0.8.4",
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.2",
9
+ "version": "0.8.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ani-mcp",
14
- "version": "0.8.2",
14
+ "version": "0.8.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },