anime-filler-mcp 1.0.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 ADDED
@@ -0,0 +1,53 @@
1
+ # anime filler mcp
2
+
3
+ The question every weeb asks: **is this episode filler?**
4
+
5
+ Powered by [animefillerlist.com](https://www.animefillerlist.com) (scraped
6
+ politely, no official API exists).
7
+
8
+ ## Tools
9
+
10
+ - `search_anime`, find an anime, get its slug
11
+ - `get_episode_lists`, full canon/filler breakdown with episode ranges
12
+ - `is_episode_filler`, verdict for one episode, with title + air date
13
+
14
+ ## Run
15
+
16
+ ```bash
17
+ npm install && npm run build && node dist/index.js
18
+ ```
19
+
20
+ ## Example
21
+
22
+ > "Is Naruto Shippuden episode 57 filler?"
23
+ >
24
+ > `is_episode_filler("naruto-shippuden", 57)` →
25
+ > "Episode 57 of Naruto Shippuden is FILLER, skippable, safe to skip.
26
+ > Title: 'Robbed of Sleep' (aired 2008-05-08)"
27
+
28
+ > "Which One Piece episodes can I skip?"
29
+ > `get_episode_lists("one-piece")` → 1010 canon, 94 filler, with ranges.
30
+
31
+ ## Quick start
32
+
33
+ ```bash
34
+ npm install
35
+ npm run build
36
+ node dist/index.js
37
+ ```
38
+
39
+ The server uses stdio, so it can be connected to Claude Desktop, Cursor, VS Code, MCP Inspector, or another compatible MCP client.
40
+
41
+ ## Tools at a glance
42
+
43
+ - `search_anime`: Search for an anime on animefillerlist.com and return its slug
44
+ - `get_episode_lists`: Get the full canon/filler episode breakdown for an anime by slug.
45
+ - `is_episode_filler`: Ask whether a specific episode is filler or canon. The question every weeb asks.
46
+
47
+ ## Limits and privacy
48
+
49
+ This project is intentionally narrow. It should be treated as a practical helper, not a complete certification or security audit. Check the implementation and the returned data before using it with sensitive material. No credentials are required unless the project explicitly says otherwise.
50
+
51
+ ## Try it
52
+
53
+ After building, connect the server through your MCP client. The repository root also contains `smoke-test.mjs` for projects covered by the shared harness. A typical tool call starts with `search_anime`.
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { createServer } from "./server.js";
3
+ async function main() {
4
+ const server = createServer();
5
+ const transport = new StdioServerTransport();
6
+ await server.connect(transport);
7
+ console.error("Anime Filler MCP server running on stdio");
8
+ }
9
+ main().catch((err) => {
10
+ console.error("Fatal error:", err);
11
+ process.exit(1);
12
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * animefillerlist.com scraper.
3
+ *
4
+ * The site tracks which anime episodes are manga canon, mixed
5
+ * canon/filler, anime canon, or pure filler. No official API, so this
6
+ * is a polite HTML scraper.
7
+ *
8
+ * Show pages contain an episode table — one <tr id="eps-N"> per episode
9
+ * with a Number cell, a Title cell, and a Type cell (Manga Canon,
10
+ * Mixed Canon/Filler, Anime Canon, Filler...). We parse that table.
11
+ */
12
+ export declare class ScrapeError extends Error {
13
+ }
14
+ export interface AnimeResult {
15
+ title: string;
16
+ slug: string;
17
+ url: string;
18
+ }
19
+ export interface Episode {
20
+ number: number;
21
+ title: string;
22
+ category: string;
23
+ date?: string;
24
+ }
25
+ export interface EpisodeCategory {
26
+ label: string;
27
+ episodes: number[];
28
+ }
29
+ export interface ShowInfo {
30
+ title: string;
31
+ slug: string;
32
+ url: string;
33
+ totalEpisodes?: number;
34
+ status?: string;
35
+ categories: EpisodeCategory[];
36
+ episodes: Episode[];
37
+ }
38
+ export declare function searchAnime(query: string): Promise<AnimeResult[]>;
39
+ export declare function getShow(slug: string): Promise<ShowInfo>;
40
+ export declare function verdict(info: ShowInfo, episode: number): string;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * animefillerlist.com scraper.
3
+ *
4
+ * The site tracks which anime episodes are manga canon, mixed
5
+ * canon/filler, anime canon, or pure filler. No official API, so this
6
+ * is a polite HTML scraper.
7
+ *
8
+ * Show pages contain an episode table — one <tr id="eps-N"> per episode
9
+ * with a Number cell, a Title cell, and a Type cell (Manga Canon,
10
+ * Mixed Canon/Filler, Anime Canon, Filler...). We parse that table.
11
+ */
12
+ import * as cheerio from "cheerio";
13
+ const BASE = "https://www.animefillerlist.com";
14
+ const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
15
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
16
+ export class ScrapeError extends Error {
17
+ }
18
+ async function fetchHtml(url) {
19
+ const res = await fetch(url, {
20
+ headers: {
21
+ "User-Agent": UA,
22
+ Accept: "text/html,application/xhtml+xml",
23
+ "Accept-Language": "en-US,en;q=0.9",
24
+ },
25
+ });
26
+ if (!res.ok) {
27
+ throw new ScrapeError(`HTTP ${res.status} fetching ${url}`);
28
+ }
29
+ return await res.text();
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // Search
33
+ // ---------------------------------------------------------------------------
34
+ export async function searchAnime(query) {
35
+ const html = await fetchHtml(`${BASE}/search/node?keys=${encodeURIComponent(query)}`);
36
+ const $ = cheerio.load(html);
37
+ const results = [];
38
+ $(".search-results a, ol li h3 a, li h3 a").each((_, el) => {
39
+ const href = $(el).attr("href") ?? "";
40
+ const m = /\/shows\/([\w-]+)/.exec(href);
41
+ if (!m)
42
+ return;
43
+ const title = $(el).text().trim();
44
+ if (!title)
45
+ return;
46
+ results.push({ title, slug: m[1], url: `${BASE}/shows/${m[1]}` });
47
+ });
48
+ if (results.length === 0) {
49
+ $('a[href*="/shows/"]').each((_, el) => {
50
+ const href = $(el).attr("href") ?? "";
51
+ const m = /\/shows\/([\w-]+)/.exec(href);
52
+ if (!m)
53
+ return;
54
+ const title = $(el).text().trim();
55
+ if (!title || results.some((r) => r.slug === m[1]))
56
+ return;
57
+ results.push({ title, slug: m[1], url: `${BASE}/shows/${m[1]}` });
58
+ });
59
+ }
60
+ return dedupe(results).slice(0, 10);
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // Show page
64
+ // ---------------------------------------------------------------------------
65
+ export async function getShow(slug) {
66
+ const url = `${BASE}/shows/${slug}`;
67
+ const html = await fetchHtml(url);
68
+ const $ = cheerio.load(html);
69
+ const rawTitle = $("h1").first().text().trim() || slug.replace(/-/g, " ");
70
+ const title = rawTitle.replace(/\s*Filler List\s*$/i, "").trim();
71
+ const headerText = $("#main-content, .content, main").first().text();
72
+ const status = /Status:\s*([^,\n]+)/i.exec(headerText);
73
+ // The episode table is the ground truth.
74
+ const episodes = [];
75
+ $("tr[id^='eps-']").each((_, el) => {
76
+ const $el = $(el);
77
+ const number = parseInt($el.find("td.Number").text().trim(), 10);
78
+ const category = $el.find("td.Type span, td.Type").first().text().trim();
79
+ if (!number || !category)
80
+ return;
81
+ episodes.push({
82
+ number,
83
+ category,
84
+ title: $el.find("td.Title").text().trim() || "?",
85
+ date: $el.find("td.Date").text().trim() || undefined,
86
+ });
87
+ });
88
+ // Group by category label.
89
+ const byLabel = new Map();
90
+ for (const ep of episodes) {
91
+ const arr = byLabel.get(ep.category) ?? [];
92
+ arr.push(ep.number);
93
+ byLabel.set(ep.category, arr);
94
+ }
95
+ const categories = [...byLabel.entries()].map(([label, nums]) => ({ label, episodes: nums.sort((a, b) => a - b) }));
96
+ // If the table was empty, fall back to the condensed quick-list.
97
+ if (episodes.length === 0) {
98
+ $("#Condensed strong").each((_, el) => {
99
+ const label = $(el).text().replace(/:$/, "").trim();
100
+ if (!label)
101
+ return;
102
+ const parsed = parseEpisodeText($(el).parent().text());
103
+ categories.push({ label, episodes: parsed.numbers });
104
+ });
105
+ }
106
+ const maxEp = episodes.reduce((m, e) => Math.max(m, e.number), 0);
107
+ return {
108
+ title,
109
+ slug,
110
+ url,
111
+ totalEpisodes: maxEp || undefined,
112
+ status: status?.[1]?.trim(),
113
+ categories,
114
+ episodes,
115
+ };
116
+ }
117
+ export function verdict(info, episode) {
118
+ if (episode <= 0)
119
+ return "Episode numbers start at 1.";
120
+ const total = info.totalEpisodes;
121
+ if (total && episode > total) {
122
+ return `Episode ${episode} is past the show's ${total} episodes.`;
123
+ }
124
+ const ep = info.episodes.find((e) => e.number === episode);
125
+ if (ep) {
126
+ const verdicts = {
127
+ "Manga Canon": "MANGA CANON — essential, do not skip",
128
+ "Mixed Canon/Filler": "MIXED CANON/FILLER — watch it, half of it counts",
129
+ "Anime Canon": "ANIME CANON — watchable, canon to the anime universe",
130
+ Filler: "FILLER — skippable, safe to skip",
131
+ "Semi-Filler": "SEMI-FILLER — partial skip",
132
+ };
133
+ const v = verdicts[ep.category] ??
134
+ (ep.category.includes("Filler") ? "FILLER-ish" : "CANON-ish");
135
+ return (`Episode ${episode} of ${info.title} is ${v}.` +
136
+ (ep.title !== "?" ? `\nTitle: "${ep.title}"` : "") +
137
+ (ep.date ? ` (aired ${ep.date})` : ""));
138
+ }
139
+ if (info.episodes.length > 0) {
140
+ return `Episode ${episode} of ${info.title} isn't in the tracked list (episodes go up to ${info.totalEpisodes}).`;
141
+ }
142
+ return `No episode data could be parsed for ${info.title}. The site's layout may have changed.`;
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Helpers
146
+ // ---------------------------------------------------------------------------
147
+ function parseEpisodeText(raw) {
148
+ const display = raw.replace(/\s+/g, " ").trim().slice(0, 300);
149
+ const numbers = new Set();
150
+ const tokenRe = /(\d+)\s*-\s*(\d+)|(\d+)/g;
151
+ let m;
152
+ while ((m = tokenRe.exec(raw)) !== null) {
153
+ if (m[1] !== undefined && m[2] !== undefined) {
154
+ const lo = Math.min(parseInt(m[1], 10), parseInt(m[2], 10));
155
+ const hi = Math.max(parseInt(m[1], 10), parseInt(m[2], 10));
156
+ for (let e = lo; e <= hi; e++)
157
+ numbers.add(e);
158
+ }
159
+ else if (m[3] !== undefined) {
160
+ numbers.add(parseInt(m[3], 10));
161
+ }
162
+ }
163
+ return { numbers: [...numbers].sort((a, b) => a - b), display };
164
+ }
165
+ function dedupe(results) {
166
+ const seen = new Set();
167
+ return results.filter((r) => {
168
+ if (seen.has(r.slug))
169
+ return false;
170
+ seen.add(r.slug);
171
+ return true;
172
+ });
173
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function createServer(): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,105 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ScrapeError, getShow, searchAnime, verdict } from "./scraper.js";
4
+ const text = (t) => ({ content: [{ type: "text", text: t }] });
5
+ const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
6
+ const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
7
+ export function createServer() {
8
+ const server = new McpServer({
9
+ name: "anime-filler-mcp",
10
+ version: "1.0.0",
11
+ });
12
+ server.registerTool("search_anime", {
13
+ title: "Search anime",
14
+ description: "Search for an anime on animefillerlist.com and return its slug " +
15
+ "for the other tools.",
16
+ inputSchema: z.object({ query: z.string().describe("Anime title, e.g. 'Naruto Shippuden' or 'One Piece'") }),
17
+ annotations: READ_ONLY,
18
+ }, async ({ query }) => {
19
+ try {
20
+ const results = await searchAnime(query);
21
+ if (results.length === 0) {
22
+ return text(`No anime found for "${query}".`);
23
+ }
24
+ return text(`Anime matching "${query}":\n` +
25
+ results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url} (slug: ${r.slug})`).join("\n"));
26
+ }
27
+ catch (e) {
28
+ return textError(errorMessage(e));
29
+ }
30
+ });
31
+ server.registerTool("get_episode_lists", {
32
+ title: "Get episode lists",
33
+ description: "Get the full canon/filler episode breakdown for an anime by slug.",
34
+ inputSchema: z.object({ slug: z.string().describe("Anime slug from search_anime, e.g. 'naruto-shippuden'") }),
35
+ annotations: READ_ONLY,
36
+ }, async ({ slug }) => {
37
+ try {
38
+ const info = await getShow(slug);
39
+ const head = `${info.title} — ${info.totalEpisodes ? info.totalEpisodes + " episodes" : "?"}` +
40
+ (info.status ? `, ${info.status}` : "") +
41
+ `\n${info.url}\n`;
42
+ if (info.categories.length === 0) {
43
+ return text(head + "No episode categories could be parsed from the page.");
44
+ }
45
+ const body = info.categories
46
+ .map((c) => `• ${c.label} (${c.episodes.length} eps): ${formatRanges(c.episodes)}`)
47
+ .join("\n");
48
+ const sample = info.episodes.length > 0
49
+ ? `\n\nSample (first 8):\n` +
50
+ info.episodes
51
+ .slice(0, 8)
52
+ .map((e) => ` ep${e.number} [${e.category}] ${e.title}`)
53
+ .join("\n")
54
+ : "";
55
+ return text(head + "\n" + body + sample);
56
+ }
57
+ catch (e) {
58
+ return textError(errorMessage(e));
59
+ }
60
+ });
61
+ server.registerTool("is_episode_filler", {
62
+ title: "Is episode filler",
63
+ description: "Ask whether a specific episode is filler or canon. The question every weeb asks.",
64
+ inputSchema: z.object({
65
+ slug: z.string().describe("Anime slug from search_anime"),
66
+ episode: z.number().int().min(1).describe("Episode number"),
67
+ }),
68
+ annotations: READ_ONLY,
69
+ }, async ({ slug, episode }) => {
70
+ try {
71
+ const info = await getShow(slug);
72
+ return text(verdict(info, episode));
73
+ }
74
+ catch (e) {
75
+ return textError(errorMessage(e));
76
+ }
77
+ });
78
+ return server;
79
+ }
80
+ function errorMessage(e) {
81
+ if (e instanceof ScrapeError)
82
+ return `Error: ${e.message}`;
83
+ if (e instanceof Error)
84
+ return `Error: ${e.message}`;
85
+ return `Error: ${String(e)}`;
86
+ }
87
+ /** Compact "1-17, 20-21, 35" representation of a sorted episode list. */
88
+ function formatRanges(nums) {
89
+ if (nums.length === 0)
90
+ return "—";
91
+ const parts = [];
92
+ let start = nums[0];
93
+ let prev = nums[0];
94
+ for (let i = 1; i <= nums.length; i++) {
95
+ const n = nums[i];
96
+ if (n === prev + 1) {
97
+ prev = n;
98
+ continue;
99
+ }
100
+ parts.push(start === prev ? `${start}` : `${start}-${prev}`);
101
+ start = prev = n;
102
+ }
103
+ const s = parts.join(", ");
104
+ return s.length > 160 ? s.slice(0, 157) + "…" : s;
105
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "anime-filler-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Use this MCP server to tells you which anime episodes are filler vs canon. Powered by animefillerlist.com",
5
+ "type": "module",
6
+ "mcpName": "io.github.mrfentmen/anime-filler-mcp",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/mrfentmen/anime-filler-mcp.git"
10
+ },
11
+ "bin": {
12
+ "anime-filler-mcp": "./dist/index.js"
13
+ },
14
+ "main": "./dist/index.js",
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "start": "node dist/index.js",
21
+ "dev": "rm -rf dist && tsc -p tsconfig.json && node dist/index.js",
22
+ "inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "anime",
27
+ "filler",
28
+ "canon",
29
+ "episodes",
30
+ "weeb"
31
+ ],
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.0.4",
35
+ "cheerio": "^1.0.0",
36
+ "zod": "^3.23.8"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.0.0",
40
+ "typescript": "^5.6.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ }
45
+ }