ani-mcp 0.1.2 → 0.2.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 +27 -2
- package/dist/api/queries.d.ts +8 -0
- package/dist/api/queries.js +86 -0
- package/dist/engine/matcher.d.ts +20 -0
- package/dist/engine/matcher.js +61 -0
- package/dist/engine/mood.d.ts +5 -0
- package/dist/engine/mood.js +48 -0
- package/dist/engine/similar.d.ts +9 -0
- package/dist/engine/similar.js +50 -0
- package/dist/index.js +16 -1
- package/dist/schemas.d.ts +75 -0
- package/dist/schemas.js +142 -0
- package/dist/tools/discover.js +15 -9
- package/dist/tools/info.js +127 -7
- package/dist/tools/lists.js +8 -5
- package/dist/tools/recommend.js +155 -4
- package/dist/tools/search.js +13 -8
- package/dist/tools/write.d.ts +4 -0
- package/dist/tools/write.js +121 -0
- package/dist/types.d.ts +76 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +8 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/dist/intelligence/matcher.d.ts +0 -12
- package/dist/intelligence/matcher.js +0 -134
- package/dist/intelligence/mood.d.ts +0 -17
- package/dist/intelligence/mood.js +0 -125
- package/dist/intelligence/taste.d.ts +0 -30
- package/dist/intelligence/taste.js +0 -172
- package/dist/tools/smart.d.ts +0 -4
- package/dist/tools/smart.js +0 -311
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Most AniList integrations mirror the API 1:1. ani-mcp adds an intelligence layer
|
|
|
11
11
|
- **Compatibility** - compare taste between two users
|
|
12
12
|
- **Year in review** - your watching/reading stats wrapped up
|
|
13
13
|
|
|
14
|
-
Plus the essentials: search, details, trending, seasonal browsing, list management, and community recommendations.
|
|
14
|
+
Plus the essentials: search, details, trending, seasonal browsing, list management, and community recommendations. All search and browse tools support pagination for browsing beyond the first page of results.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -38,8 +38,11 @@ Works with any MCP-compatible client.
|
|
|
38
38
|
| Variable | Required | Description |
|
|
39
39
|
| --- | --- | --- |
|
|
40
40
|
| `ANILIST_USERNAME` | No | Default username for list and stats tools. Can also pass per-call. |
|
|
41
|
-
| `ANILIST_TOKEN` | No | AniList OAuth token.
|
|
41
|
+
| `ANILIST_TOKEN` | No | AniList OAuth token. Required for write operations and private lists. |
|
|
42
42
|
| `DEBUG` | No | Set to `true` for debug logging to stderr. |
|
|
43
|
+
| `MCP_TRANSPORT` | No | Set to `http` for HTTP Stream transport. Default: stdio. |
|
|
44
|
+
| `MCP_PORT` | No | Port for HTTP transport. Default: `3000`. |
|
|
45
|
+
| `MCP_HOST` | No | Host for HTTP transport. Default: `localhost`. |
|
|
43
46
|
|
|
44
47
|
## Tools
|
|
45
48
|
|
|
@@ -69,15 +72,37 @@ Works with any MCP-compatible client.
|
|
|
69
72
|
| `anilist_pick` | Personalized "what to watch next" based on taste and mood |
|
|
70
73
|
| `anilist_compare` | Compare taste compatibility between two users |
|
|
71
74
|
| `anilist_wrapped` | Year-in-review summary |
|
|
75
|
+
| `anilist_explain` | "Why would I like this?" - score a title against your taste profile |
|
|
76
|
+
| `anilist_similar` | Find titles similar to a given anime or manga |
|
|
72
77
|
|
|
73
78
|
### Info
|
|
74
79
|
|
|
75
80
|
| Tool | Description |
|
|
76
81
|
| --- | --- |
|
|
77
82
|
| `anilist_staff` | Staff credits and voice actors for a title |
|
|
83
|
+
| `anilist_staff_search` | Search for a person by name and see all their works |
|
|
84
|
+
| `anilist_studio_search` | Search for a studio and see their productions |
|
|
78
85
|
| `anilist_schedule` | Airing schedule and next episode countdown |
|
|
79
86
|
| `anilist_characters` | Search characters by name with appearances and VAs |
|
|
80
87
|
|
|
88
|
+
### Write (requires `ANILIST_TOKEN`)
|
|
89
|
+
|
|
90
|
+
| Tool | Description |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `anilist_update_progress` | Update episode or chapter progress |
|
|
93
|
+
| `anilist_add_to_list` | Add a title to your list with a status |
|
|
94
|
+
| `anilist_rate` | Score a title (0-10) |
|
|
95
|
+
| `anilist_delete_from_list` | Remove an entry from your list |
|
|
96
|
+
|
|
97
|
+
## Docker
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
docker build -t ani-mcp .
|
|
101
|
+
docker run -e ANILIST_USERNAME=your_username ani-mcp
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Runs on port 3000 with HTTP Stream transport by default.
|
|
105
|
+
|
|
81
106
|
## Build from Source
|
|
82
107
|
|
|
83
108
|
```sh
|
package/dist/api/queries.d.ts
CHANGED
|
@@ -26,5 +26,13 @@ export declare const STAFF_QUERY = "\n query MediaStaff($id: Int, $search: Stri
|
|
|
26
26
|
export declare const AIRING_SCHEDULE_QUERY = "\n query AiringSchedule($id: Int, $search: String, $notYetAired: Boolean) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n status\n episodes\n nextAiringEpisode {\n episode\n airingAt\n timeUntilAiring\n }\n airingSchedule(notYetAired: $notYetAired, perPage: 10) {\n nodes {\n episode\n airingAt\n timeUntilAiring\n }\n }\n siteUrl\n }\n }\n";
|
|
27
27
|
/** Search for characters by name */
|
|
28
28
|
export declare const CHARACTER_SEARCH_QUERY = "\n query CharacterSearch($search: String!, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n characters(search: $search, sort: FAVOURITES_DESC) {\n id\n name { full native alternative }\n image { medium }\n favourites\n siteUrl\n media(sort: POPULARITY_DESC, perPage: 5) {\n edges {\n characterRole\n node {\n id\n title { romaji english }\n format\n type\n siteUrl\n }\n voiceActors(language: JAPANESE) {\n id\n name { full }\n siteUrl\n }\n }\n }\n }\n }\n }\n";
|
|
29
|
+
/** Create or update a list entry */
|
|
30
|
+
export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaListEntry(\n $mediaId: Int\n $status: MediaListStatus\n $score: Float\n $progress: Int\n $notes: String\n $private: Boolean\n ) {\n SaveMediaListEntry(\n mediaId: $mediaId\n status: $status\n score: $score\n progress: $progress\n notes: $notes\n private: $private\n ) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n }\n }\n";
|
|
31
|
+
/** Remove a list entry */
|
|
32
|
+
export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
|
|
29
33
|
/** User's anime/manga list, grouped by status. Omit $status to get all lists. */
|
|
30
34
|
export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
35
|
+
/** Search for staff by name with their top works */
|
|
36
|
+
export declare const STAFF_SEARCH_QUERY = "\n query StaffSearch($search: String!, $page: Int, $perPage: Int, $mediaPerPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n staff(search: $search, sort: SEARCH_MATCH) {\n id\n name { full native }\n primaryOccupations\n siteUrl\n staffMedia(sort: POPULARITY_DESC, perPage: $mediaPerPage) {\n edges {\n staffRole\n node {\n id\n title { romaji english }\n format\n type\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n }\n";
|
|
37
|
+
/** Search for a studio by name with their productions */
|
|
38
|
+
export declare const STUDIO_SEARCH_QUERY = "\n query StudioSearch($search: String!, $perPage: Int) {\n Studio(search: $search, sort: SEARCH_MATCH) {\n id\n name\n isAnimationStudio\n siteUrl\n media(sort: POPULARITY_DESC, perPage: $perPage) {\n edges {\n isMainStudio\n node {\n id\n title { romaji english }\n format\n type\n status\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n";
|
package/dist/api/queries.js
CHANGED
|
@@ -364,6 +364,40 @@ export const CHARACTER_SEARCH_QUERY = `
|
|
|
364
364
|
}
|
|
365
365
|
}
|
|
366
366
|
`;
|
|
367
|
+
/** Create or update a list entry */
|
|
368
|
+
export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
|
|
369
|
+
mutation SaveMediaListEntry(
|
|
370
|
+
$mediaId: Int
|
|
371
|
+
$status: MediaListStatus
|
|
372
|
+
$score: Float
|
|
373
|
+
$progress: Int
|
|
374
|
+
$notes: String
|
|
375
|
+
$private: Boolean
|
|
376
|
+
) {
|
|
377
|
+
SaveMediaListEntry(
|
|
378
|
+
mediaId: $mediaId
|
|
379
|
+
status: $status
|
|
380
|
+
score: $score
|
|
381
|
+
progress: $progress
|
|
382
|
+
notes: $notes
|
|
383
|
+
private: $private
|
|
384
|
+
) {
|
|
385
|
+
id
|
|
386
|
+
mediaId
|
|
387
|
+
status
|
|
388
|
+
score(format: POINT_10)
|
|
389
|
+
progress
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
`;
|
|
393
|
+
/** Remove a list entry */
|
|
394
|
+
export const DELETE_MEDIA_LIST_ENTRY_MUTATION = `
|
|
395
|
+
mutation DeleteMediaListEntry($id: Int!) {
|
|
396
|
+
DeleteMediaListEntry(id: $id) {
|
|
397
|
+
deleted
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
`;
|
|
367
401
|
/** User's anime/manga list, grouped by status. Omit $status to get all lists. */
|
|
368
402
|
export const USER_LIST_QUERY = `
|
|
369
403
|
query UserMediaList(
|
|
@@ -399,3 +433,55 @@ export const USER_LIST_QUERY = `
|
|
|
399
433
|
}
|
|
400
434
|
${MEDIA_FRAGMENT}
|
|
401
435
|
`;
|
|
436
|
+
/** Search for staff by name with their top works */
|
|
437
|
+
export const STAFF_SEARCH_QUERY = `
|
|
438
|
+
query StaffSearch($search: String!, $page: Int, $perPage: Int, $mediaPerPage: Int) {
|
|
439
|
+
Page(page: $page, perPage: $perPage) {
|
|
440
|
+
pageInfo { total hasNextPage }
|
|
441
|
+
staff(search: $search, sort: SEARCH_MATCH) {
|
|
442
|
+
id
|
|
443
|
+
name { full native }
|
|
444
|
+
primaryOccupations
|
|
445
|
+
siteUrl
|
|
446
|
+
staffMedia(sort: POPULARITY_DESC, perPage: $mediaPerPage) {
|
|
447
|
+
edges {
|
|
448
|
+
staffRole
|
|
449
|
+
node {
|
|
450
|
+
id
|
|
451
|
+
title { romaji english }
|
|
452
|
+
format
|
|
453
|
+
type
|
|
454
|
+
meanScore
|
|
455
|
+
siteUrl
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
`;
|
|
463
|
+
/** Search for a studio by name with their productions */
|
|
464
|
+
export const STUDIO_SEARCH_QUERY = `
|
|
465
|
+
query StudioSearch($search: String!, $perPage: Int) {
|
|
466
|
+
Studio(search: $search, sort: SEARCH_MATCH) {
|
|
467
|
+
id
|
|
468
|
+
name
|
|
469
|
+
isAnimationStudio
|
|
470
|
+
siteUrl
|
|
471
|
+
media(sort: POPULARITY_DESC, perPage: $perPage) {
|
|
472
|
+
edges {
|
|
473
|
+
isMainStudio
|
|
474
|
+
node {
|
|
475
|
+
id
|
|
476
|
+
title { romaji english }
|
|
477
|
+
format
|
|
478
|
+
type
|
|
479
|
+
status
|
|
480
|
+
meanScore
|
|
481
|
+
siteUrl
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
`;
|
package/dist/engine/matcher.d.ts
CHANGED
|
@@ -8,5 +8,25 @@ export interface MatchResult {
|
|
|
8
8
|
reasons: string[];
|
|
9
9
|
moodFit: string | null;
|
|
10
10
|
}
|
|
11
|
+
export interface ScoreBreakdown {
|
|
12
|
+
genreScore: number;
|
|
13
|
+
tagScore: number;
|
|
14
|
+
communityScore: number;
|
|
15
|
+
popularityFactor: number;
|
|
16
|
+
moodMultiplier: number;
|
|
17
|
+
finalScore: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ExplainResult {
|
|
20
|
+
media: AniListMedia;
|
|
21
|
+
breakdown: ScoreBreakdown;
|
|
22
|
+
matchedGenres: string[];
|
|
23
|
+
unmatchedGenres: string[];
|
|
24
|
+
matchedTags: string[];
|
|
25
|
+
unmatchedTags: string[];
|
|
26
|
+
reasons: string[];
|
|
27
|
+
moodFit: string | null;
|
|
28
|
+
}
|
|
11
29
|
/** Score and rank candidates against a user's taste profile */
|
|
12
30
|
export declare function matchCandidates(candidates: AniListMedia[], profile: TasteProfile, mood?: MoodModifiers): MatchResult[];
|
|
31
|
+
/** Score a single title against a taste profile with a detailed breakdown */
|
|
32
|
+
export declare function explainMatch(media: AniListMedia, profile: TasteProfile, mood?: MoodModifiers): ExplainResult;
|
package/dist/engine/matcher.js
CHANGED
|
@@ -139,6 +139,67 @@ function applyMood(media, mood, reasons) {
|
|
|
139
139
|
}
|
|
140
140
|
return null;
|
|
141
141
|
}
|
|
142
|
+
// === Single-Title Explain ===
|
|
143
|
+
/** Score a single title against a taste profile with a detailed breakdown */
|
|
144
|
+
export function explainMatch(media, profile, mood) {
|
|
145
|
+
const genreWeights = toWeightMap(profile.genres);
|
|
146
|
+
const tagWeights = toWeightMap(profile.tags);
|
|
147
|
+
const maxGenreWeight = profile.genres[0]?.weight ?? 1;
|
|
148
|
+
const maxTagWeight = profile.tags[0]?.weight ?? 1;
|
|
149
|
+
const reasons = [];
|
|
150
|
+
// Genre affinity with matched/unmatched tracking
|
|
151
|
+
const genreScore = computeGenreAffinity(media, genreWeights, maxGenreWeight, reasons);
|
|
152
|
+
const matchedGenres = media.genres.filter((g) => genreWeights.has(g));
|
|
153
|
+
const unmatchedGenres = media.genres.filter((g) => !genreWeights.has(g));
|
|
154
|
+
// Tag affinity with matched/unmatched tracking
|
|
155
|
+
const tagScore = computeTagAffinity(media, tagWeights, maxTagWeight, reasons);
|
|
156
|
+
const nonSpoilerTags = media.tags.filter((t) => !t.isMediaSpoiler);
|
|
157
|
+
const matchedTags = nonSpoilerTags
|
|
158
|
+
.filter((t) => tagWeights.has(t.name))
|
|
159
|
+
.map((t) => t.name);
|
|
160
|
+
const unmatchedTags = nonSpoilerTags
|
|
161
|
+
.filter((t) => !tagWeights.has(t.name))
|
|
162
|
+
.map((t) => t.name);
|
|
163
|
+
const communityScore = (media.meanScore ?? 70) / 100;
|
|
164
|
+
const popFactor = popularityDiversityFactor(media.popularity);
|
|
165
|
+
let finalScore = genreScore * GENRE_WEIGHT +
|
|
166
|
+
tagScore * TAG_WEIGHT +
|
|
167
|
+
communityScore * COMMUNITY_WEIGHT;
|
|
168
|
+
finalScore *= popFactor;
|
|
169
|
+
// Mood modifier
|
|
170
|
+
let moodMultiplier = 1;
|
|
171
|
+
const moodFit = mood ? applyMood(media, mood, reasons) : null;
|
|
172
|
+
if (moodFit === "boost") {
|
|
173
|
+
moodMultiplier = MOOD_BOOST;
|
|
174
|
+
finalScore *= MOOD_BOOST;
|
|
175
|
+
}
|
|
176
|
+
if (moodFit === "penalty") {
|
|
177
|
+
moodMultiplier = MOOD_PENALTY;
|
|
178
|
+
finalScore *= MOOD_PENALTY;
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
media,
|
|
182
|
+
breakdown: {
|
|
183
|
+
genreScore,
|
|
184
|
+
tagScore,
|
|
185
|
+
communityScore,
|
|
186
|
+
popularityFactor: popFactor,
|
|
187
|
+
moodMultiplier,
|
|
188
|
+
// Scale 0-1 to 0-100
|
|
189
|
+
finalScore: Math.round(Math.min(1, finalScore) * 100),
|
|
190
|
+
},
|
|
191
|
+
matchedGenres,
|
|
192
|
+
unmatchedGenres,
|
|
193
|
+
matchedTags,
|
|
194
|
+
unmatchedTags,
|
|
195
|
+
reasons,
|
|
196
|
+
moodFit: moodFit
|
|
197
|
+
? moodFit === "boost"
|
|
198
|
+
? "Strong mood match"
|
|
199
|
+
: "Weak mood fit"
|
|
200
|
+
: null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
142
203
|
// === Helpers ===
|
|
143
204
|
/** Convert WeightedItem[] to a Map for fast lookup */
|
|
144
205
|
function toWeightMap(items) {
|
package/dist/engine/mood.d.ts
CHANGED
|
@@ -15,3 +15,8 @@ export declare function parseMood(mood: string): MoodModifiers;
|
|
|
15
15
|
export declare function hasMoodMatch(mood: string): boolean;
|
|
16
16
|
/** List all recognized mood keywords */
|
|
17
17
|
export declare function getMoodKeywords(): string[];
|
|
18
|
+
/** Suggest mood keywords that fit the current anime season */
|
|
19
|
+
export declare function seasonalMoodSuggestions(): {
|
|
20
|
+
season: string;
|
|
21
|
+
moods: string[];
|
|
22
|
+
};
|
package/dist/engine/mood.js
CHANGED
|
@@ -66,6 +66,24 @@ const BASE_MOOD_RULES = {
|
|
|
66
66
|
boost: ["Psychological", "Avant Garde", "Surreal", "Experimental"],
|
|
67
67
|
penalize: ["Shounen", "Sports"],
|
|
68
68
|
},
|
|
69
|
+
nostalgic: {
|
|
70
|
+
boost: [
|
|
71
|
+
"Coming of Age",
|
|
72
|
+
"Drama",
|
|
73
|
+
"Slice of Life",
|
|
74
|
+
"School",
|
|
75
|
+
"Ensemble Cast",
|
|
76
|
+
],
|
|
77
|
+
penalize: ["Isekai", "Mecha"],
|
|
78
|
+
},
|
|
79
|
+
artistic: {
|
|
80
|
+
boost: ["Avant Garde", "Drama", "Music", "Surreal", "Visual Arts"],
|
|
81
|
+
penalize: ["Shounen", "Ecchi"],
|
|
82
|
+
},
|
|
83
|
+
competitive: {
|
|
84
|
+
boost: ["Sports", "Strategy Game", "Shounen", "Tournament", "Martial Arts"],
|
|
85
|
+
penalize: ["Slice of Life", "Iyashikei"],
|
|
86
|
+
},
|
|
69
87
|
};
|
|
70
88
|
// Synonyms that resolve to a base keyword's rules
|
|
71
89
|
const MOOD_SYNONYMS = {
|
|
@@ -117,6 +135,17 @@ const MOOD_SYNONYMS = {
|
|
|
117
135
|
// trippy
|
|
118
136
|
surreal: "trippy",
|
|
119
137
|
experimental: "trippy",
|
|
138
|
+
// nostalgic
|
|
139
|
+
retro: "nostalgic",
|
|
140
|
+
throwback: "nostalgic",
|
|
141
|
+
classic: "nostalgic",
|
|
142
|
+
// artistic
|
|
143
|
+
artsy: "artistic",
|
|
144
|
+
beautiful: "artistic",
|
|
145
|
+
aesthetic: "artistic",
|
|
146
|
+
// competitive
|
|
147
|
+
rivalry: "competitive",
|
|
148
|
+
tournament: "competitive",
|
|
120
149
|
};
|
|
121
150
|
// Merge base rules and synonyms into a single lookup
|
|
122
151
|
const MOOD_RULES = { ...BASE_MOOD_RULES };
|
|
@@ -163,3 +192,22 @@ export function hasMoodMatch(mood) {
|
|
|
163
192
|
export function getMoodKeywords() {
|
|
164
193
|
return Object.keys(MOOD_RULES);
|
|
165
194
|
}
|
|
195
|
+
// === Seasonal Suggestions ===
|
|
196
|
+
const SEASONAL_MOODS = {
|
|
197
|
+
WINTER: ["cozy", "dark", "nostalgic", "brainy"],
|
|
198
|
+
SPRING: ["romantic", "wholesome", "chill", "artistic"],
|
|
199
|
+
SUMMER: ["hype", "action", "epic", "competitive"],
|
|
200
|
+
FALL: ["mystery", "scary", "dark", "intense"],
|
|
201
|
+
};
|
|
202
|
+
/** Suggest mood keywords that fit the current anime season */
|
|
203
|
+
export function seasonalMoodSuggestions() {
|
|
204
|
+
const month = new Date().getMonth() + 1;
|
|
205
|
+
const season = month <= 3
|
|
206
|
+
? "WINTER"
|
|
207
|
+
: month <= 6
|
|
208
|
+
? "SPRING"
|
|
209
|
+
: month <= 9
|
|
210
|
+
? "SUMMER"
|
|
211
|
+
: "FALL";
|
|
212
|
+
return { season, moods: SEASONAL_MOODS[season] };
|
|
213
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Ranks candidates by content similarity to a source title */
|
|
2
|
+
import type { AniListMedia } from "../types.js";
|
|
3
|
+
export interface SimilarResult {
|
|
4
|
+
media: AniListMedia;
|
|
5
|
+
similarityScore: number;
|
|
6
|
+
reasons: string[];
|
|
7
|
+
}
|
|
8
|
+
/** Rank candidates by genre/tag overlap and community recommendation strength */
|
|
9
|
+
export declare function rankSimilar(source: AniListMedia, candidates: AniListMedia[], recRatings: Map<number, number>): SimilarResult[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Ranks candidates by content similarity to a source title */
|
|
2
|
+
// === Constants ===
|
|
3
|
+
const GENRE_WEIGHT = 0.4;
|
|
4
|
+
const TAG_WEIGHT = 0.3;
|
|
5
|
+
const REC_WEIGHT = 0.3;
|
|
6
|
+
// === Similarity Engine ===
|
|
7
|
+
/** Rank candidates by genre/tag overlap and community recommendation strength */
|
|
8
|
+
export function rankSimilar(source, candidates, recRatings) {
|
|
9
|
+
if (candidates.length === 0)
|
|
10
|
+
return [];
|
|
11
|
+
// Normalize rec ratings to 0-1
|
|
12
|
+
const maxRating = Math.max(1, ...recRatings.values());
|
|
13
|
+
const sourceGenres = new Set(source.genres);
|
|
14
|
+
const sourceTags = new Set(source.tags.filter((t) => !t.isMediaSpoiler).map((t) => t.name));
|
|
15
|
+
const results = [];
|
|
16
|
+
for (const candidate of candidates) {
|
|
17
|
+
const reasons = [];
|
|
18
|
+
// Genre overlap: Jaccard coefficient
|
|
19
|
+
const candidateGenres = new Set(candidate.genres);
|
|
20
|
+
const genreIntersection = [...sourceGenres].filter((g) => candidateGenres.has(g));
|
|
21
|
+
const genreUnion = new Set([...sourceGenres, ...candidateGenres]);
|
|
22
|
+
const genreOverlap = genreUnion.size > 0 ? genreIntersection.length / genreUnion.size : 0;
|
|
23
|
+
if (genreIntersection.length > 0) {
|
|
24
|
+
reasons.push(`Shares genres: ${genreIntersection.join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
// Tag overlap: Jaccard on non-spoiler tags
|
|
27
|
+
const candidateTags = new Set(candidate.tags.filter((t) => !t.isMediaSpoiler).map((t) => t.name));
|
|
28
|
+
const tagIntersection = [...sourceTags].filter((t) => candidateTags.has(t));
|
|
29
|
+
const tagUnion = new Set([...sourceTags, ...candidateTags]);
|
|
30
|
+
const tagOverlap = tagUnion.size > 0 ? tagIntersection.length / tagUnion.size : 0;
|
|
31
|
+
if (tagIntersection.length > 0) {
|
|
32
|
+
reasons.push(`Similar themes: ${tagIntersection.slice(0, 3).join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
// Community recommendation rating
|
|
35
|
+
const rating = recRatings.get(candidate.id) ?? 0;
|
|
36
|
+
const recBoost = rating > 0 ? rating / maxRating : 0;
|
|
37
|
+
if (rating > 0) {
|
|
38
|
+
reasons.push(`Recommended by community (${rating > 0 ? "+" : ""}${rating})`);
|
|
39
|
+
}
|
|
40
|
+
const score = genreOverlap * GENRE_WEIGHT +
|
|
41
|
+
tagOverlap * TAG_WEIGHT +
|
|
42
|
+
recBoost * REC_WEIGHT;
|
|
43
|
+
results.push({
|
|
44
|
+
media: candidate,
|
|
45
|
+
similarityScore: Math.round(Math.min(1, score) * 100),
|
|
46
|
+
reasons,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return results.sort((a, b) => b.similarityScore - a.similarityScore);
|
|
50
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { registerListTools } from "./tools/lists.js";
|
|
|
7
7
|
import { registerRecommendTools } from "./tools/recommend.js";
|
|
8
8
|
import { registerDiscoverTools } from "./tools/discover.js";
|
|
9
9
|
import { registerInfoTools } from "./tools/info.js";
|
|
10
|
+
import { registerWriteTools } from "./tools/write.js";
|
|
10
11
|
// Both vars are optional - warn on missing so operators know what's available
|
|
11
12
|
if (!process.env.ANILIST_USERNAME) {
|
|
12
13
|
console.warn("ANILIST_USERNAME not set - tools will require a username argument.");
|
|
@@ -23,4 +24,18 @@ registerListTools(server);
|
|
|
23
24
|
registerRecommendTools(server);
|
|
24
25
|
registerDiscoverTools(server);
|
|
25
26
|
registerInfoTools(server);
|
|
26
|
-
server
|
|
27
|
+
registerWriteTools(server);
|
|
28
|
+
// === Transport ===
|
|
29
|
+
const transport = process.env.MCP_TRANSPORT === "http" ? "httpStream" : "stdio";
|
|
30
|
+
if (transport === "httpStream") {
|
|
31
|
+
const port = Number(process.env.MCP_PORT) || 3000;
|
|
32
|
+
const host = process.env.MCP_HOST || "localhost";
|
|
33
|
+
console.error(`Listening on http://${host}:${port}/mcp`);
|
|
34
|
+
server.start({
|
|
35
|
+
transportType: "httpStream",
|
|
36
|
+
httpStream: { port, host },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
server.start({ transportType: "stdio" });
|
|
41
|
+
}
|
package/dist/schemas.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export declare const SearchInputSchema: z.ZodObject<{
|
|
|
21
21
|
}>>;
|
|
22
22
|
isAdult: z.ZodDefault<z.ZodBoolean>;
|
|
23
23
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
24
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
24
25
|
}, z.core.$strip>;
|
|
25
26
|
export type SearchInput = z.infer<typeof SearchInputSchema>;
|
|
26
27
|
/** Input for looking up a single anime or manga by ID or title */
|
|
@@ -51,6 +52,7 @@ export declare const ListInputSchema: z.ZodObject<{
|
|
|
51
52
|
PROGRESS: "PROGRESS";
|
|
52
53
|
}>>;
|
|
53
54
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
55
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
54
56
|
}, z.core.$strip>;
|
|
55
57
|
export type ListInput = z.infer<typeof ListInputSchema>;
|
|
56
58
|
/** Input for generating a taste profile summary */
|
|
@@ -101,6 +103,7 @@ export declare const SeasonalInputSchema: z.ZodObject<{
|
|
|
101
103
|
}>>;
|
|
102
104
|
isAdult: z.ZodDefault<z.ZodBoolean>;
|
|
103
105
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
106
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
104
107
|
}, z.core.$strip>;
|
|
105
108
|
export type SeasonalInput = z.infer<typeof SeasonalInputSchema>;
|
|
106
109
|
/** Input for fetching user statistics */
|
|
@@ -127,6 +130,7 @@ export declare const TrendingInputSchema: z.ZodObject<{
|
|
|
127
130
|
}>>;
|
|
128
131
|
isAdult: z.ZodDefault<z.ZodBoolean>;
|
|
129
132
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
133
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
130
134
|
}, z.core.$strip>;
|
|
131
135
|
export type TrendingInput = z.infer<typeof TrendingInputSchema>;
|
|
132
136
|
/** Input for browsing by genre */
|
|
@@ -161,6 +165,7 @@ export declare const GenreBrowseInputSchema: z.ZodObject<{
|
|
|
161
165
|
}>>;
|
|
162
166
|
isAdult: z.ZodDefault<z.ZodBoolean>;
|
|
163
167
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
168
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
164
169
|
}, z.core.$strip>;
|
|
165
170
|
export type GenreBrowseInput = z.infer<typeof GenreBrowseInputSchema>;
|
|
166
171
|
/** Input for staff/VA credits lookup */
|
|
@@ -179,6 +184,7 @@ export type ScheduleInput = z.infer<typeof ScheduleInputSchema>;
|
|
|
179
184
|
export declare const CharacterSearchInputSchema: z.ZodObject<{
|
|
180
185
|
query: z.ZodString;
|
|
181
186
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
187
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
182
188
|
}, z.core.$strip>;
|
|
183
189
|
export type CharacterSearchInput = z.infer<typeof CharacterSearchInputSchema>;
|
|
184
190
|
/** Input for community recommendations for a specific title */
|
|
@@ -188,3 +194,72 @@ export declare const RecommendationsInputSchema: z.ZodObject<{
|
|
|
188
194
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
189
195
|
}, z.core.$strip>;
|
|
190
196
|
export type RecommendationsInput = z.infer<typeof RecommendationsInputSchema>;
|
|
197
|
+
/** Input for updating episode or chapter progress */
|
|
198
|
+
export declare const UpdateProgressInputSchema: z.ZodObject<{
|
|
199
|
+
mediaId: z.ZodNumber;
|
|
200
|
+
progress: z.ZodNumber;
|
|
201
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
202
|
+
CURRENT: "CURRENT";
|
|
203
|
+
COMPLETED: "COMPLETED";
|
|
204
|
+
DROPPED: "DROPPED";
|
|
205
|
+
PAUSED: "PAUSED";
|
|
206
|
+
REPEATING: "REPEATING";
|
|
207
|
+
}>>;
|
|
208
|
+
}, z.core.$strip>;
|
|
209
|
+
export type UpdateProgressInput = z.infer<typeof UpdateProgressInputSchema>;
|
|
210
|
+
/** Input for adding a title to the user's list */
|
|
211
|
+
export declare const AddToListInputSchema: z.ZodObject<{
|
|
212
|
+
mediaId: z.ZodNumber;
|
|
213
|
+
status: z.ZodEnum<{
|
|
214
|
+
CURRENT: "CURRENT";
|
|
215
|
+
COMPLETED: "COMPLETED";
|
|
216
|
+
PLANNING: "PLANNING";
|
|
217
|
+
DROPPED: "DROPPED";
|
|
218
|
+
PAUSED: "PAUSED";
|
|
219
|
+
REPEATING: "REPEATING";
|
|
220
|
+
}>;
|
|
221
|
+
score: z.ZodOptional<z.ZodNumber>;
|
|
222
|
+
}, z.core.$strip>;
|
|
223
|
+
export type AddToListInput = z.infer<typeof AddToListInputSchema>;
|
|
224
|
+
/** Input for rating a title */
|
|
225
|
+
export declare const RateInputSchema: z.ZodObject<{
|
|
226
|
+
mediaId: z.ZodNumber;
|
|
227
|
+
score: z.ZodNumber;
|
|
228
|
+
}, z.core.$strip>;
|
|
229
|
+
export type RateInput = z.infer<typeof RateInputSchema>;
|
|
230
|
+
/** Input for removing a title from the list */
|
|
231
|
+
export declare const DeleteFromListInputSchema: z.ZodObject<{
|
|
232
|
+
entryId: z.ZodNumber;
|
|
233
|
+
}, z.core.$strip>;
|
|
234
|
+
/** Input for scoring a title against a user's taste profile */
|
|
235
|
+
export declare const ExplainInputSchema: z.ZodObject<{
|
|
236
|
+
mediaId: z.ZodNumber;
|
|
237
|
+
username: z.ZodOptional<z.ZodString>;
|
|
238
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
239
|
+
ANIME: "ANIME";
|
|
240
|
+
MANGA: "MANGA";
|
|
241
|
+
BOTH: "BOTH";
|
|
242
|
+
}>>;
|
|
243
|
+
mood: z.ZodOptional<z.ZodString>;
|
|
244
|
+
}, z.core.$strip>;
|
|
245
|
+
export type ExplainInput = z.infer<typeof ExplainInputSchema>;
|
|
246
|
+
/** Input for finding titles similar to a specific anime or manga */
|
|
247
|
+
export declare const SimilarInputSchema: z.ZodObject<{
|
|
248
|
+
mediaId: z.ZodNumber;
|
|
249
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
250
|
+
}, z.core.$strip>;
|
|
251
|
+
export type SimilarInput = z.infer<typeof SimilarInputSchema>;
|
|
252
|
+
/** Input for searching staff/people by name */
|
|
253
|
+
export declare const StaffSearchInputSchema: z.ZodObject<{
|
|
254
|
+
query: z.ZodString;
|
|
255
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
256
|
+
mediaLimit: z.ZodDefault<z.ZodNumber>;
|
|
257
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
258
|
+
}, z.core.$strip>;
|
|
259
|
+
export type StaffSearchInput = z.infer<typeof StaffSearchInputSchema>;
|
|
260
|
+
/** Input for searching studios by name */
|
|
261
|
+
export declare const StudioSearchInputSchema: z.ZodObject<{
|
|
262
|
+
query: z.ZodString;
|
|
263
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
264
|
+
}, z.core.$strip>;
|
|
265
|
+
export type StudioSearchInput = z.infer<typeof StudioSearchInputSchema>;
|