Build an AI Manga Librarian with MCP
Build a private AI manga librarian inside Claude Desktop using MCP.
Introduction
30 Second Summary
You love manga, manhwa, and manhua. But every time you want to discover something new or check for chapter updates, you end up on ad-infested aggregator sites full of pop-ups, malware links, and pirated content. You spend an hour scrolling instead of two minutes reading.
In this project, you will build a private AI librarian that runs inside Claude Desktop via MCP. It discovers titles across all three origins, verifies every link against legal platforms, and tracks your reading in a local database you own.
What You'll Build
A custom Node.js MCP server that turns Claude Desktop into a personal manga, manhwa, and manhua discovery engine and library manager with verified legal links and a daily digest.
By the end of this project, you'll have:
- A working MCP server exposing discovery tools that browse categories (popular, top, new releases, new chapters, by genre, completed) across manga (JP), manhwa (KR), and manhua (CN) with every link verified against a legal-platform allowlist.
- A private, local-first SQLite library tracking titles from bucket list through reading, completed, and re-reading with chapter progress, ratings, and notes.
- A daily digest by origin running on Windows Task Scheduler, turning discovery into a two-minute morning habit.
- Secret Mission: Add because-you-read recommendations, hidden gems, and an adversarial verify pass that red-teams every link.
Are there any prerequisites?
You need a Claude Pro or Max subscription with the Claude Desktop app installed on Windows. You also need Node.js (LTS) and a code editor like VS Code or Cursor.
Set Up the Project and Run Preflight
Before you can discover manga, manhwa, and manhua through Claude, you need a foundation: a project folder, the right packages, and confirmation that the metadata APIs you depend on are actually reachable.
In this step you will scaffold the project, run a preflight script that pings each API and records its status, write a legal-platform allowlist, and wire the project into Claude Desktop so it can call your tools.
In this step, get ready to:
- Initialize the project folder and install dependencies.
- Run the preflight script to confirm AniList, MangaDex, and Jikan are reachable.
- Configure Claude Desktop to load the MCP server.
Initialize the project and install dependencies
Your project needs a home folder, an initialized package.json, and two packages: better-sqlite3 for the local library database, and zod for schema validation in MCP tool inputs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and initialize npm by running these commands:
mkdir manga-librarian
cd manga-librarian
npm init -y
What does this do?
mkdir creates the folder. cd moves your terminal into it. npm init -y generates a default package.json so npm can track your dependencies.
- Confirm the folder was created by running:
ls
You should see package.json listed in the output.
- Install the two project dependencies by running:
npm install better-sqlite3@12.11.1 zod@3.25.67
Why pin exact versions?
Pinning better-sqlite3@12.11.1 and zod@3.25.67 guarantees everyone running this project gets the same behaviour. A floating range could pull a breaking change.
- Verify the install succeeded by running:
node -e "require('better-sqlite3'); require('zod'); console.log('Dependencies OK')"
You should see Dependencies OK printed with no errors.
Seeing a compilation error for better-sqlite3?
better-sqlite3 includes a native C++ addon. On Windows you may need the Visual C++ Build Tools. If the install fails with a build error, install the windows-build-tools package or the Visual Studio Build Tools from the Microsoft site, then re-run the install command.
Help me fix the better-sqlite3 install error
- Create the project directory structure by running:
mkdir src data config
- Confirm the folders exist by running:
ls
You should see config, data, node_modules, package-lock.json, package.json, and src in the output.
Write and run the preflight script
The preflight script pings each metadata API, records whether it responded and what rate-limit headers it returned, and writes a dated report to config/preflight-report.json. It also writes the legal-platform allowlist to config/allowlist.json.
- In your code editor, create a new file at src/preflight.js inside your manga-librarian folder.
- Add the constants and allowlist at the top of the file by pasting this code:
// src/preflight.js
// Preflight check: pings metadata APIs and writes config files
const fs = require('fs');
const path = require('path');
// Legal reading platforms whose domains we trust for link verification
const LEGAL_PLATFORMS = [
'webtoon.com',
'tapas.io',
'mangaplus.shueisha.co.jp',
'viz.com',
'kmanga.kodansha.com',
'comikey.com',
'inkr.com',
'tappytoon.com',
'lezhin.com',
'manta.net',
'azuki.co',
'crunchyroll.com'
];
// API endpoints to test during preflight
const API_TESTS = [
{
name: 'AniList GraphQL',
url: 'https://graphql.anilist.co',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ GenreCollection }' })
},
{
name: 'MangaDex REST',
url: 'https://api.mangadex.org/manga?limit=1',
method: 'GET'
},
{
name: 'Jikan v4',
url: 'https://api.jikan.moe/v4/manga?limit=1',
method: 'GET'
}
];
What does this code do?
LEGAL_PLATFORMS lists the domains you will later verify links against. If a URL points to one of these domains, you can trust it as a legitimate reading source.
API_TESTS defines the three metadata sources the project depends on. AniList uses GraphQL (a POST with a simple query). MangaDex and Jikan use REST (a GET with a minimal limit).
- Below the constants, add the function that tests a single API:
// Test a single API endpoint and capture rate-limit headers
async function testApi(api) {
try {
const options = { method: api.method, headers: api.headers || {} };
if (api.body) options.body = api.body;
const response = await fetch(api.url, options);
// Collect any rate-limit or retry headers
const rateLimits = {};
for (const [key, value] of response.headers.entries()) {
if (
key.toLowerCase().includes('rate') ||
key.toLowerCase().includes('limit') ||
key.toLowerCase().includes('retry')
) {
rateLimits[key] = value;
}
}
return {
name: api.name,
url: api.url,
reachable: response.ok,
status: response.status,
rateLimits,
testedAt: new Date().toISOString()
};
} catch (error) {
return {
name: api.name,
url: api.url,
reachable: false,
error: error.message,
testedAt: new Date().toISOString()
};
}
}
What does this function do?
testApi sends an HTTP request to the given endpoint and returns an object describing whether it succeeded. It also scrapes any response headers containing "rate", "limit", or "retry" so you can see what throttling rules apply.
- Below testApi, add the main runPreflight function that orchestrates the checks and writes the output files:
// Run all preflight checks and write config files
async function runPreflight() {
console.log('Running preflight checks...\n');
// Test each API sequentially
const results = [];
for (const api of API_TESTS) {
console.log(`Testing ${api.name}...`);
const result = await testApi(api);
results.push(result);
if (result.reachable) {
console.log(` OK: ${api.name} responded with status ${result.status}`);
} else {
console.log(` FAIL: ${api.name} - ${result.error || 'status ' + result.status}`);
}
}
// Build and write the preflight report
const report = {
generatedAt: new Date().toISOString(),
allReachable: results.every(r => r.reachable),
apis: results
};
const configDir = path.join(__dirname, '..', 'config');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const reportPath = path.join(configDir, 'preflight-report.json');
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\nReport written to config/preflight-report.json`);
// Write the legal platform allowlist
const allowlist = {
description: 'Legal manga/manhwa/manhua reading platforms',
domains: LEGAL_PLATFORMS,
updatedAt: new Date().toISOString()
};
const allowlistPath = path.join(configDir, 'allowlist.json');
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist, null, 2));
console.log('Allowlist written to config/allowlist.json');
// Print summary
console.log('\n--- Preflight Summary ---');
if (report.allReachable) {
console.log('All APIs reachable. Ready to proceed.');
} else {
const unreachable = results.filter(r => !r.reachable).map(r => r.name);
console.log(`WARNING: Unreachable APIs: ${unreachable.join(', ')}`);
console.log('Some features may not work until these APIs are available.');
}
}
runPreflight();
What does this code do?
runPreflight loops through each API test, logs the result, then writes two files. config/preflight-report.json contains a dated record of each API's reachability and rate-limit headers. config/allowlist.json stores the legal platform domains you will use later to verify links.
- Save src/preflight.js.
- Run the preflight script by executing this command:
node src/preflight.js
You should see each API tested with an OK status, then confirmation that both config files were written.
Seeing a fetch error or FAIL for one of the APIs?
If your Node.js version is below 18, the global fetch function is not available. Run node --version to check. You need Node.js 18 or higher for built-in fetch support.
If a specific API returns FAIL, it may be temporarily down. The script still writes the report. Re-run it after a few minutes. If it persists, check your internet connection.
Help me fix the preflight script errors
- Confirm the output files were created by running:
ls config
You should see allowlist.json and preflight-report.json listed.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/preflight.js and that both config files exist.
ⓧ I'd like to double check the full code
Here is the complete src/preflight.js file for comparison:
// src/preflight.js
// Preflight check: pings metadata APIs and writes config files
const fs = require('fs');
const path = require('path');
// Legal reading platforms whose domains we trust for link verification
const LEGAL_PLATFORMS = [
'webtoon.com',
'tapas.io',
'mangaplus.shueisha.co.jp',
'viz.com',
'kmanga.kodansha.com',
'comikey.com',
'inkr.com',
'tappytoon.com',
'lezhin.com',
'manta.net',
'azuki.co',
'crunchyroll.com'
];
// API endpoints to test during preflight
const API_TESTS = [
{
name: 'AniList GraphQL',
url: 'https://graphql.anilist.co',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ GenreCollection }' })
},
{
name: 'MangaDex REST',
url: 'https://api.mangadex.org/manga?limit=1',
method: 'GET'
},
{
name: 'Jikan v4',
url: 'https://api.jikan.moe/v4/manga?limit=1',
method: 'GET'
}
];
// Test a single API endpoint and capture rate-limit headers
async function testApi(api) {
try {
const options = { method: api.method, headers: api.headers || {} };
if (api.body) options.body = api.body;
const response = await fetch(api.url, options);
// Collect any rate-limit or retry headers
const rateLimits = {};
for (const [key, value] of response.headers.entries()) {
if (
key.toLowerCase().includes('rate') ||
key.toLowerCase().includes('limit') ||
key.toLowerCase().includes('retry')
) {
rateLimits[key] = value;
}
}
return {
name: api.name,
url: api.url,
reachable: response.ok,
status: response.status,
rateLimits,
testedAt: new Date().toISOString()
};
} catch (error) {
return {
name: api.name,
url: api.url,
reachable: false,
error: error.message,
testedAt: new Date().toISOString()
};
}
}
// Run all preflight checks and write config files
async function runPreflight() {
console.log('Running preflight checks...\n');
// Test each API sequentially
const results = [];
for (const api of API_TESTS) {
console.log(`Testing ${api.name}...`);
const result = await testApi(api);
results.push(result);
if (result.reachable) {
console.log(` OK: ${api.name} responded with status ${result.status}`);
} else {
console.log(` FAIL: ${api.name} - ${result.error || 'status ' + result.status}`);
}
}
// Build and write the preflight report
const report = {
generatedAt: new Date().toISOString(),
allReachable: results.every(r => r.reachable),
apis: results
};
const configDir = path.join(__dirname, '..', 'config');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const reportPath = path.join(configDir, 'preflight-report.json');
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\nReport written to config/preflight-report.json`);
// Write the legal platform allowlist
const allowlist = {
description: 'Legal manga/manhwa/manhua reading platforms',
domains: LEGAL_PLATFORMS,
updatedAt: new Date().toISOString()
};
const allowlistPath = path.join(configDir, 'allowlist.json');
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist, null, 2));
console.log('Allowlist written to config/allowlist.json');
// Print summary
console.log('\n--- Preflight Summary ---');
if (report.allReachable) {
console.log('All APIs reachable. Ready to proceed.');
} else {
const unreachable = results.filter(r => !r.reachable).map(r => r.name);
console.log(`WARNING: Unreachable APIs: ${unreachable.join(', ')}`);
console.log('Some features may not work until these APIs are available.');
}
}
runPreflight();
Configure Claude Desktop to load the MCP server
Claude Desktop discovers MCP servers through a JSON config file. You will add an entry pointing to the index file you will build in the next step.
- First, find the absolute path to your project folder. In your terminal (still inside manga-librarian), run:
cd
The output shows your current directory path. Note this down. You will need it in the config.
- Open the Claude Desktop config file. Its location on Windows is:
%APPDATA%\Claude\claude_desktop_config.json
- Press the Windows key to open your search bar.
- Type Run and press Enter to open the Run dialog.
- Paste %APPDATA%\Claude into the Run dialog and press Enter.
- Open claude_desktop_config.json in your code editor. If the file does not exist, create it.
- Add or update the file so it contains the following structure (replace the path with your actual project path):
{
"mcpServers": {
"manga-librarian": {
"command": "node",
"args": ["[[PROJECT_PATH=\"C:\\\\Users\\\\YourName\\\\Desktop\\\\manga-librarian\\\\src\\\\index.js\"]]]"]
}
}
}
What is this config doing?
The mcpServers object tells Claude Desktop which local processes to launch as MCP tool servers. Each entry names a server (manga-librarian) and specifies the command to run it. Claude will start this process and communicate with it over stdio.
The path must be absolute and use double backslashes on Windows. Forward slashes also work.
Existing config entries
If claude_desktop_config.json already has other servers listed, add the manga-librarian entry inside the existing mcpServers object. Do not overwrite other entries.
- Save claude_desktop_config.json.
- Fully quit Claude Desktop (right-click the system tray icon and choose Quit).
- Reopen Claude Desktop.
How do I verify the server entry?
Click the plus icon (Add files, connectors, and more) in the chat input, then hover over the Connectors menu. You should see manga-librarian listed. It may show as disconnected because the server file (src/index.js) does not exist yet. That is expected. You will build it in the next step.
Don't see manga-librarian in Connectors?
Check that the JSON in claude_desktop_config.json is valid. A missing comma or bracket will silently prevent Claude from loading the config. Paste your file contents into a JSON validator to spot syntax errors.
Also confirm you fully quit Claude Desktop before reopening. Closing the window alone does not restart the app on Windows.
You can also check the MCP logs at %APPDATA%\Claude\logs for error details.
Help me fix my Claude Desktop MCP config
Your environment is ready. The APIs are confirmed reachable, your allowlist is in place, and Claude Desktop knows where your server will live. Next up, you will build the discovery engine that fills browse categories across manga, manhwa, and manhua.
Build the Discovery Engine
Your preflight check confirmed that AniList, MangaDex, and Jikan are all reachable and ready to serve metadata.
Now you need to turn those raw API endpoints into a discovery engine that fills browse categories, verifies every link against your legal-platform allowlist, and wires the results into MCP tools Claude can call.
In this step, get ready to:
- Build a metadata fetcher module with caching and rate-limit spacing for all three APIs.
- Build a category engine that fills popular, top, new releases, new chapters, by genre, and completed categories across manga, manhwa, and manhua.
- Build a verify-and-synthesize module that checks every link against the allowlist and outputs verified results with confidence labels.
Build the metadata fetcher module
Each API has different rate limits and response shapes. A shared fetcher module gives every downstream function a consistent interface, automatic caching, and polite spacing between requests.
- Install the MCP SDK so your server entry point can register tools later, by running this command:
npm install @modelcontextprotocol/sdk@1.29.0
- Add "type": "module" to your package.json so Node.js treats .js files as ES modules. Open package.json in your editor and add this field right after the "version" line:
"type": "module",
Why type module?
The MCP SDK uses ES module imports (import ... from ...). Setting "type": "module" in package.json tells Node.js to use ES module syntax throughout your project without needing .mjs file extensions.
- Create the fetcher module by opening a new file at src/fetchers.js in your editor.
- Add the cache and rate-limit infrastructure by pasting this code:
// In-memory cache with 15-minute TTL
const cache = new Map();
const CACHE_TTL = 15 * 60 * 1000;
function getCached(key) {
const entry = cache.get(key);
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
return entry.data;
}
cache.delete(key);
return null;
}
function setCache(key, data) {
cache.set(key, { data, timestamp: Date.now() });
}
// Rate limit spacing (ms between requests per API)
const lastCall = { anilist: 0, mangadex: 0, jikan: 0 };
const RATE_LIMITS = { anilist: 1000, mangadex: 500, jikan: 1000 };
async function rateLimit(api) {
const now = Date.now();
const elapsed = now - lastCall[api];
if (elapsed < RATE_LIMITS[api]) {
await new Promise(r => setTimeout(r, RATE_LIMITS[api] - elapsed));
}
lastCall[api] = Date.now();
}
What does this code do?
The cache stores API responses in a Map keyed by request signature. Any response younger than 15 minutes is returned instantly without hitting the API again.
The rate limiter tracks the last call time for each API and inserts a setTimeout delay if the next call comes too soon. This keeps you well within each API's acceptable-use limits.
- Add the fetchAniList function below the rate-limit code:
// Fetch from AniList GraphQL
export async function fetchAniList(query, variables = {}) {
const cacheKey = `anilist:${JSON.stringify({ query, variables })}`;
const cached = getCached(cacheKey);
if (cached) return cached;
await rateLimit('anilist');
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables })
});
if (!response.ok) {
throw new Error(`AniList error: ${response.status}`);
}
const data = await response.json();
setCache(cacheKey, data);
return data;
}
What does this code do?
This function posts a GraphQL query to https://graphql.anilist.co with any variables you pass in. It checks the cache first, waits for rate-limit clearance, then stores the response for future calls.
AniList requires no authentication for read queries. The countryOfOrigin variable is what lets you separate manga (JP), manhwa (KR), and manhua (CN) in a single flexible query.
- Add the fetchMangaDex and fetchJikan functions below fetchAniList:
// Fetch from MangaDex REST API
export async function fetchMangaDex(params = {}) {
const url = new URL('https://api.mangadex.org/manga');
// Include author and cover art by default
url.searchParams.append('includes[]', 'author');
url.searchParams.append('includes[]', 'cover_art');
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach(v => url.searchParams.append(`${key}[]`, v));
} else {
url.searchParams.set(key, value);
}
}
const cacheKey = `mangadex:${url.toString()}`;
const cached = getCached(cacheKey);
if (cached) return cached;
await rateLimit('mangadex');
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`MangaDex error: ${response.status}`);
}
const data = await response.json();
setCache(cacheKey, data);
return data;
}
// Fetch from Jikan v4 (MyAnimeList unofficial API)
export async function fetchJikan(endpoint, params = {}) {
const url = new URL(`https://api.jikan.moe/v4/${endpoint}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const cacheKey = `jikan:${url.toString()}`;
const cached = getCached(cacheKey);
if (cached) return cached;
await rateLimit('jikan');
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Jikan error: ${response.status}`);
}
const data = await response.json();
setCache(cacheKey, data);
return data;
}
What does this code do?
fetchMangaDex builds a URL against the /manga endpoint and automatically includes author and cover art reference expansions. Array parameters like includedTags are appended with the [] suffix MangaDex expects.
fetchJikan calls Jikan v4 at https://api.jikan.moe/v4/ with a flexible endpoint parameter. You can use it for top manga, manga search, or manga by ID. Both functions follow the same cache-then-fetch pattern.
- Save src/fetchers.js.
- Verify the module loads without errors by running:
node -e "import('./src/fetchers.js').then(() => console.log('fetchers loaded'))"
You should see fetchers loaded in your terminal.
Seeing a syntax or module error?
If you see Cannot use import statement outside a module, confirm that your package.json has "type": "module" set. The field must be inside the top-level JSON object.
If you see SyntaxError: Unexpected token, check for missing commas or brackets in the file.
help me debug my fetchers.js module loading error
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/fetchers.js before moving on.
ⓧ I'd like to double check the full code
// In-memory cache with 15-minute TTL
const cache = new Map();
const CACHE_TTL = 15 * 60 * 1000;
function getCached(key) {
const entry = cache.get(key);
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
return entry.data;
}
cache.delete(key);
return null;
}
function setCache(key, data) {
cache.set(key, { data, timestamp: Date.now() });
}
// Rate limit spacing (ms between requests per API)
const lastCall = { anilist: 0, mangadex: 0, jikan: 0 };
const RATE_LIMITS = { anilist: 1000, mangadex: 500, jikan: 1000 };
async function rateLimit(api) {
const now = Date.now();
const elapsed = now - lastCall[api];
if (elapsed < RATE_LIMITS[api]) {
await new Promise(r => setTimeout(r, RATE_LIMITS[api] - elapsed));
}
lastCall[api] = Date.now();
}
// Fetch from AniList GraphQL
export async function fetchAniList(query, variables = {}) {
const cacheKey = `anilist:${JSON.stringify({ query, variables })}`;
const cached = getCached(cacheKey);
if (cached) return cached;
await rateLimit('anilist');
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables })
});
if (!response.ok) {
throw new Error(`AniList error: ${response.status}`);
}
const data = await response.json();
setCache(cacheKey, data);
return data;
}
// Fetch from MangaDex REST API
export async function fetchMangaDex(params = {}) {
const url = new URL('https://api.mangadex.org/manga');
// Include author and cover art by default
url.searchParams.append('includes[]', 'author');
url.searchParams.append('includes[]', 'cover_art');
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach(v => url.searchParams.append(`${key}[]`, v));
} else {
url.searchParams.set(key, value);
}
}
const cacheKey = `mangadex:${url.toString()}`;
const cached = getCached(cacheKey);
if (cached) return cached;
await rateLimit('mangadex');
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`MangaDex error: ${response.status}`);
}
const data = await response.json();
setCache(cacheKey, data);
return data;
}
// Fetch from Jikan v4 (MyAnimeList unofficial API)
export async function fetchJikan(endpoint, params = {}) {
const url = new URL(`https://api.jikan.moe/v4/${endpoint}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const cacheKey = `jikan:${url.toString()}`;
const cached = getCached(cacheKey);
if (cached) return cached;
await rateLimit('jikan');
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Jikan error: ${response.status}`);
}
const data = await response.json();
setCache(cacheKey, data);
return data;
}
Build the category engine
The category engine turns raw API parameters into named browse categories. Each function maps to a way the user might explore: trending titles, all-time greats, fresh series, genre shelves, and more.
- Create a new file at src/categories.js in your editor.
- Add the AniList browse query and the popular, top, and new releases categories:
import { fetchAniList, fetchMangaDex } from './fetchers.js';
// AniList browse query for manga/manhwa/manhua discovery
const BROWSE_QUERY = `
query ($page: Int, $perPage: Int, $sort: [MediaSort], $status: MediaStatus, $countryOfOrigin: CountryCode, $genre_in: [String], $startDate_greater: FuzzyDateInt, $popularity_greater: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { hasNextPage }
media(type: MANGA, sort: $sort, status: $status, countryOfOrigin: $countryOfOrigin, genre_in: $genre_in, startDate_greater: $startDate_greater, popularity_greater: $popularity_greater) {
id
title { english romaji }
countryOfOrigin
genres
status
description
averageScore
popularity
externalLinks { url site }
}
}
}`;
// Popular: momentum-based trending titles
export async function getPopular(origin = null, page = 1) {
const variables = { page, perPage: 20, sort: ['TRENDING_DESC'] };
if (origin) variables.countryOfOrigin = origin;
const result = await fetchAniList(BROWSE_QUERY, variables);
return result.data.Page.media;
}
// Top: all-time highest rated with enough votes to be meaningful
export async function getTop(origin = null, page = 1) {
const variables = {
page,
perPage: 20,
sort: ['SCORE_DESC'],
popularity_greater: 5000
};
if (origin) variables.countryOfOrigin = origin;
const result = await fetchAniList(BROWSE_QUERY, variables);
return result.data.Page.media;
}
// New releases: brand-new series from the last 30 days
export async function getNewReleases(origin = null, page = 1) {
const thirtyDaysAgo = Number(
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString().slice(0, 10).replace(/-/g, '')
);
const variables = {
page,
perPage: 10,
sort: ['POPULARITY_DESC'],
startDate_greater: thirtyDaysAgo
};
if (origin) variables.countryOfOrigin = origin;
const anilistResults = await fetchAniList(BROWSE_QUERY, variables);
// Also get freshly created series from MangaDex
const mdParams = { 'order[createdAt]': 'desc', limit: '10' };
const mdResults = await fetchMangaDex(mdParams);
return {
anilist: anilistResults.data.Page.media,
mangadex: mdResults.data || []
};
}
What does this code do?
BROWSE_QUERY is a single flexible GraphQL query that powers most categories. By changing the sort, status, and countryOfOrigin variables, you get completely different browse results from the same query structure.
getPopular uses TRENDING_DESC to surface titles with current momentum. getTop uses SCORE_DESC with a popularity_greater threshold so you only see titles that enough people have rated. getNewReleases combines AniList's recent starts with MangaDex's newly created entries for broader coverage.
- Add the new chapters, by genre, and completed categories below the existing code:
// New chapters: latest uploads across MangaDex
export async function getNewChapters() {
const params = {
'order[latestUploadedChapter]': 'desc',
limit: '20'
};
const results = await fetchMangaDex(params);
return results.data || [];
}
// By genre: filtered by genre and optionally by origin
export async function getByGenre(genre, origin = null, page = 1) {
const variables = {
page,
perPage: 20,
sort: ['POPULARITY_DESC'],
genre_in: [genre]
};
if (origin) variables.countryOfOrigin = origin;
const result = await fetchAniList(BROWSE_QUERY, variables);
return result.data.Page.media;
}
// Completed: finished series sorted by score
export async function getCompleted(origin = null, page = 1) {
const variables = {
page,
perPage: 20,
sort: ['SCORE_DESC'],
status: 'FINISHED'
};
if (origin) variables.countryOfOrigin = origin;
const result = await fetchAniList(BROWSE_QUERY, variables);
return result.data.Page.media;
}
What does this code do?
getNewChapters uses MangaDex's order[latestUploadedChapter]=desc to show titles with the freshest chapter uploads. This is your "what dropped today" feed.
getByGenre uses AniList's genre_in with countryOfOrigin for per-origin genre shelves. getCompleted returns finished series sorted by score. Perfect for binge-reading without waiting for updates.
- Save src/categories.js.
- Verify it loads correctly by running:
node -e "import('./src/categories.js').then(m => console.log(Object.keys(m)))"
You should see an array of function names: getPopular, getTop, getNewReleases, getNewChapters, getByGenre, getCompleted.
Module not found error?
If you see an error about ./fetchers.js not being found, make sure your import path includes the .js extension. ES modules in Node.js require the full file extension in relative imports.
help me fix my categories.js import error
✔️ Awesome, I've got everything!
Great. Make sure src/categories.js is saved before continuing.
ⓧ I'd like to double check the full code
import { fetchAniList, fetchMangaDex } from './fetchers.js';
// AniList browse query for manga/manhwa/manhua discovery
const BROWSE_QUERY = `
query ($page: Int, $perPage: Int, $sort: [MediaSort], $status: MediaStatus, $countryOfOrigin: CountryCode, $genre_in: [String], $startDate_greater: FuzzyDateInt, $popularity_greater: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { hasNextPage }
media(type: MANGA, sort: $sort, status: $status, countryOfOrigin: $countryOfOrigin, genre_in: $genre_in, startDate_greater: $startDate_greater, popularity_greater: $popularity_greater) {
id
title { english romaji }
countryOfOrigin
genres
status
description
averageScore
popularity
externalLinks { url site }
}
}
}`;
// Popular: momentum-based trending titles
export async function getPopular(origin = null, page = 1) {
const variables = { page, perPage: 20, sort: ['TRENDING_DESC'] };
if (origin) variables.countryOfOrigin = origin;
const result = await fetchAniList(BROWSE_QUERY, variables);
return result.data.Page.media;
}
// Top: all-time highest rated with enough votes to be meaningful
export async function getTop(origin = null, page = 1) {
const variables = {
page,
perPage: 20,
sort: ['SCORE_DESC'],
popularity_greater: 5000
};
if (origin) variables.countryOfOrigin = origin;
const result = await fetchAniList(BROWSE_QUERY, variables);
return result.data.Page.media;
}
// New releases: brand-new series from the last 30 days
export async function getNewReleases(origin = null, page = 1) {
const thirtyDaysAgo = Number(
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString().slice(0, 10).replace(/-/g, '')
);
const variables = {
page,
perPage: 10,
sort: ['POPULARITY_DESC'],
startDate_greater: thirtyDaysAgo
};
if (origin) variables.countryOfOrigin = origin;
const anilistResults = await fetchAniList(BROWSE_QUERY, variables);
// Also get freshly created series from MangaDex
const mdParams = { 'order[createdAt]': 'desc', limit: '10' };
const mdResults = await fetchMangaDex(mdParams);
return {
anilist: anilistResults.data.Page.media,
mangadex: mdResults.data || []
};
}
// New chapters: latest uploads across MangaDex
export async function getNewChapters() {
const params = {
'order[latestUploadedChapter]': 'desc',
limit: '20'
};
const results = await fetchMangaDex(params);
return results.data || [];
}
// By genre: filtered by genre and optionally by origin
export async function getByGenre(genre, origin = null, page = 1) {
const variables = {
page,
perPage: 20,
sort: ['POPULARITY_DESC'],
genre_in: [genre]
};
if (origin) variables.countryOfOrigin = origin;
const result = await fetchAniList(BROWSE_QUERY, variables);
return result.data.Page.media;
}
// Completed: finished series sorted by score
export async function getCompleted(origin = null, page = 1) {
const variables = {
page,
perPage: 20,
sort: ['SCORE_DESC'],
status: 'FINISHED'
};
if (origin) variables.countryOfOrigin = origin;
const result = await fetchAniList(BROWSE_QUERY, variables);
return result.data.Page.media;
}
Build the verify-and-synthesize module
Discovery results are only useful if their links actually go somewhere legal. The verify module checks every URL against your allowlist, confirms it resolves with an HTTP HEAD request, deduplicates across sources, and outputs a clean result with a confidence label.
- Create a new file at src/verify.js in your editor.
- Add the allowlist loading, URL checking, and resolve verification:
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Load the legal platform allowlist
const allowlist = JSON.parse(
readFileSync(join(__dirname, '..', 'config', 'allowlist.json'), 'utf-8')
);
// Check if a URL belongs to a legal platform on the allowlist
function isAllowlisted(url) {
try {
const hostname = new URL(url).hostname;
return allowlist.domains.some(domain =>
hostname === domain || hostname.endsWith('.' + domain)
);
} catch {
return false;
}
}
// HTTP HEAD check to verify a URL resolves (status 200-399)
async function resolveUrl(url) {
try {
const response = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
signal: AbortSignal.timeout(5000)
});
return response.status >= 200 && response.status < 400;
} catch {
return false;
}
}
// Normalize title for deduplication matching
function normalizeTitle(title) {
return (title || '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.trim();
}
What does this code do?
isAllowlisted parses the URL's hostname and checks it against your legal-platform allowlist (webtoon.com, viz.com, mangaplus.shueisha.co.jp, etc.). Subdomain matching ensures global.webtoon.com also passes.
resolveUrl sends an HTTP HEAD request with a 5-second timeout. If the response status is 200-399, the link is alive. This catches dead URLs and broken redirects before they reach the user.
- Add the deduplication, synthesis, and batch verification functions below the existing code:
// Deduplicate results by AniList ID, MangaDex ID, or normalized title
export function deduplicateResults(results) {
const seen = new Set();
return results.filter(item => {
const keys = [
item.anilistId ? `al:${item.anilistId}` : null,
item.mangadexId ? `md:${item.mangadexId}` : null,
`title:${normalizeTitle(item.title)}`
].filter(Boolean);
if (keys.some(k => seen.has(k))) return false;
keys.forEach(k => seen.add(k));
return true;
});
}
// Truncate description to 2 sentences for a spoiler-light overview
function truncateOverview(description) {
if (!description) return 'No description available.';
// Strip HTML tags from AniList descriptions
const clean = description.replace(/<[^>]+>/g, '');
const sentences = clean.match(/[^.!?]+[.!?]+/g) || [clean];
return sentences.slice(0, 2).join(' ').trim();
}
// Verify and synthesize a single title
export async function verifyTitle(title) {
const candidates = [];
// Collect legal links from AniList externalLinks
if (title.externalLinks) {
for (const link of title.externalLinks) {
if (link.url && isAllowlisted(link.url)) {
candidates.push(link.url);
}
}
}
// Collect from MangaDex links.engtl (official English translation URL)
if (title.engtlLink && isAllowlisted(title.engtlLink)) {
candidates.push(title.engtlLink);
}
// Resolve the first allowlisted candidate
let verifiedLink = null;
let confidence = 'abstain';
for (const url of candidates) {
const resolves = await resolveUrl(url);
if (resolves) {
verifiedLink = url;
confidence = 'high';
break;
} else {
// Link is on allowlist but did not resolve
verifiedLink = url;
confidence = 'medium';
}
}
return {
title: title.title?.english || title.title?.romaji || title.rawTitle || 'Unknown',
origin: title.countryOfOrigin || title.origin || 'Unknown',
genres: title.genres || [],
status: title.status || 'Unknown',
overview: truncateOverview(title.description),
legalLink: verifiedLink || 'no verified official source',
confidence
};
}
// Verify a batch of titles and deduplicate
export async function verifyBatch(titles) {
const results = [];
for (const title of titles) {
const verified = await verifyTitle(title);
results.push(verified);
}
return deduplicateResults(results);
}
What does this code do?
verifyTitle collects candidate URLs from AniList's externalLinks and MangaDex's links.engtl field. It checks each candidate against the allowlist, then attempts a HEAD resolve. The confidence label is high if the link resolves, medium if it is on the allowlist but unreachable, and abstain if no official link exists.
deduplicateResults prevents the same title from appearing twice when both AniList and MangaDex return it. It matches by AniList ID, MangaDex ID, or a normalized version of the title string.
- Save src/verify.js.
- Verify it loads by running:
node -e "import('./src/verify.js').then(m => console.log(Object.keys(m)))"
You should see deduplicateResults, verifyTitle, and verifyBatch in the output.
Allowlist file not found?
If you see an error about config/allowlist.json not being found, confirm the file exists in your project's config/ folder. You created it in Step 1 during the preflight substep.
help me debug my verify.js file loading error
✔️ Awesome, I've got everything!
Great. Make sure src/verify.js is saved.
ⓧ I'd like to double check the full code
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Load the legal platform allowlist
const allowlist = JSON.parse(
readFileSync(join(__dirname, '..', 'config', 'allowlist.json'), 'utf-8')
);
// Check if a URL belongs to a legal platform on the allowlist
function isAllowlisted(url) {
try {
const hostname = new URL(url).hostname;
return allowlist.domains.some(domain =>
hostname === domain || hostname.endsWith('.' + domain)
);
} catch {
return false;
}
}
// HTTP HEAD check to verify a URL resolves (status 200-399)
async function resolveUrl(url) {
try {
const response = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
signal: AbortSignal.timeout(5000)
});
return response.status >= 200 && response.status < 400;
} catch {
return false;
}
}
// Normalize title for deduplication matching
function normalizeTitle(title) {
return (title || '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.trim();
}
// Deduplicate results by AniList ID, MangaDex ID, or normalized title
export function deduplicateResults(results) {
const seen = new Set();
return results.filter(item => {
const keys = [
item.anilistId ? `al:${item.anilistId}` : null,
item.mangadexId ? `md:${item.mangadexId}` : null,
`title:${normalizeTitle(item.title)}`
].filter(Boolean);
if (keys.some(k => seen.has(k))) return false;
keys.forEach(k => seen.add(k));
return true;
});
}
// Truncate description to 2 sentences for a spoiler-light overview
function truncateOverview(description) {
if (!description) return 'No description available.';
// Strip HTML tags from AniList descriptions
const clean = description.replace(/<[^>]+>/g, '');
const sentences = clean.match(/[^.!?]+[.!?]+/g) || [clean];
return sentences.slice(0, 2).join(' ').trim();
}
// Verify and synthesize a single title
export async function verifyTitle(title) {
const candidates = [];
// Collect legal links from AniList externalLinks
if (title.externalLinks) {
for (const link of title.externalLinks) {
if (link.url && isAllowlisted(link.url)) {
candidates.push(link.url);
}
}
}
// Collect from MangaDex links.engtl (official English translation URL)
if (title.engtlLink && isAllowlisted(title.engtlLink)) {
candidates.push(title.engtlLink);
}
// Resolve the first allowlisted candidate
let verifiedLink = null;
let confidence = 'abstain';
for (const url of candidates) {
const resolves = await resolveUrl(url);
if (resolves) {
verifiedLink = url;
confidence = 'high';
break;
} else {
// Link is on allowlist but did not resolve
verifiedLink = url;
confidence = 'medium';
}
}
return {
title: title.title?.english || title.title?.romaji || title.rawTitle || 'Unknown',
origin: title.countryOfOrigin || title.origin || 'Unknown',
genres: title.genres || [],
status: title.status || 'Unknown',
overview: truncateOverview(title.description),
legalLink: verifiedLink || 'no verified official source',
confidence
};
}
// Verify a batch of titles and deduplicate
export async function verifyBatch(titles) {
const results = [];
for (const title of titles) {
const verified = await verifyTitle(title);
results.push(verified);
}
return deduplicateResults(results);
}
Now wire everything together into the MCP server entry point so Claude can call your discovery tools.
- Create a new file at src/index.js in your editor.
- Add the MCP server setup and discovery tool registrations:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { getPopular, getTop, getNewReleases, getNewChapters, getByGenre, getCompleted } from './categories.js';
import { verifyBatch } from './verify.js';
// Create the MCP server instance
const server = new McpServer({
name: 'manga-librarian',
version: '1.0.0'
});
// Discover popular/trending titles
server.registerTool('discover_popular', {
description: 'Browse popular trending manga, manhwa, or manhua',
inputSchema: {
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ origin }) => {
const results = await getPopular(origin || null);
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover top-rated all-time titles
server.registerTool('discover_top', {
description: 'Browse top-rated all-time manga, manhwa, or manhua',
inputSchema: {
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ origin }) => {
const results = await getTop(origin || null);
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover new releases
server.registerTool('discover_new_releases', {
description: 'Browse brand-new series from the last 30 days',
inputSchema: {
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ origin }) => {
const results = await getNewReleases(origin || null);
const allResults = [...(results.anilist || []), ...(results.mangadex || [])];
const verified = await verifyBatch(allResults);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover titles with new chapters
server.registerTool('discover_new_chapters', {
description: 'Browse titles with the latest chapter uploads',
inputSchema: {}
}, async () => {
const results = await getNewChapters();
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover titles by genre
server.registerTool('discover_by_genre', {
description: 'Browse titles filtered by genre and optionally by origin',
inputSchema: {
genre: z.string().describe('Genre to filter by (e.g. Action, Romance, Fantasy)'),
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ genre, origin }) => {
const results = await getByGenre(genre, origin || null);
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover completed series
server.registerTool('discover_completed', {
description: 'Browse completed series sorted by score',
inputSchema: {
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ origin }) => {
const results = await getCompleted(origin || null);
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Connect to Claude Desktop via stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
What does this code do?
Each registerTool call exposes a named tool that Claude Desktop can invoke. The inputSchema uses zod to define what parameters Claude can pass (like origin for filtering by JP, KR, or CN).
Every tool follows the same pattern: call the category function, pass results through verifyBatch to check links and deduplicate, then return verified JSON. The StdioServerTransport connects the server to Claude Desktop's stdin/stdout communication channel.
- Save src/index.js.
- Restart Claude Desktop so it picks up the updated server code.
- Click the plus icon in the Claude Desktop chat input, then hover over Connectors to verify your manga-librarian server appears with the discovery tools listed.
Server not showing in Connectors?
Check the MCP logs at %APPDATA%\Claude\logs for error messages. Common causes: a missing .js extension in an import path, or the absolute path in claude_desktop_config.json not matching your actual project location.
If you see a zod import error, confirm that zod is listed in your package.json dependencies and that node_modules exists in your project folder.
help me debug why my MCP server is not connecting to Claude Desktop
✔️ Awesome, I've got everything!
Great. Double check that all four files are saved: src/fetchers.js, src/categories.js, src/verify.js, and src/index.js.
ⓧ I'd like to double check the full code
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { getPopular, getTop, getNewReleases, getNewChapters, getByGenre, getCompleted } from './categories.js';
import { verifyBatch } from './verify.js';
// Create the MCP server instance
const server = new McpServer({
name: 'manga-librarian',
version: '1.0.0'
});
// Discover popular/trending titles
server.registerTool('discover_popular', {
description: 'Browse popular trending manga, manhwa, or manhua',
inputSchema: {
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ origin }) => {
const results = await getPopular(origin || null);
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover top-rated all-time titles
server.registerTool('discover_top', {
description: 'Browse top-rated all-time manga, manhwa, or manhua',
inputSchema: {
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ origin }) => {
const results = await getTop(origin || null);
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover new releases
server.registerTool('discover_new_releases', {
description: 'Browse brand-new series from the last 30 days',
inputSchema: {
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ origin }) => {
const results = await getNewReleases(origin || null);
const allResults = [...(results.anilist || []), ...(results.mangadex || [])];
const verified = await verifyBatch(allResults);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover titles with new chapters
server.registerTool('discover_new_chapters', {
description: 'Browse titles with the latest chapter uploads',
inputSchema: {}
}, async () => {
const results = await getNewChapters();
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover titles by genre
server.registerTool('discover_by_genre', {
description: 'Browse titles filtered by genre and optionally by origin',
inputSchema: {
genre: z.string().describe('Genre to filter by (e.g. Action, Romance, Fantasy)'),
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ genre, origin }) => {
const results = await getByGenre(genre, origin || null);
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Discover completed series
server.registerTool('discover_completed', {
description: 'Browse completed series sorted by score',
inputSchema: {
origin: z.enum(['JP', 'KR', 'CN']).optional().describe('Filter by origin country')
}
}, async ({ origin }) => {
const results = await getCompleted(origin || null);
const verified = await verifyBatch(results);
return { content: [{ type: 'text', text: JSON.stringify(verified, null, 2) }] };
});
// Connect to Claude Desktop via stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
Your discovery engine is live. Claude can now browse six categories of manga, manhwa, and manhua with every link verified against legal platforms. Next up, you will build the private library that tracks your reading from bucket list through completion.
Build the Private Library
Discovery is only half the picture. You can now browse manga, manhwa, and manhua across categories, but there is no way to remember which titles caught your eye or track where you left off reading.
In this step, you will build a private, local-first SQLite library that carries titles from bucket list through reading, completed, and re-reading. Every piece of data stays on your machine in a file you own and can export.
In this step, get ready to:
- Build a SQLite schema with tables for library entries, custom lists, and CRUD functions.
- Register MCP tools that let Claude manage your reading lifecycle.
- Add continue-reading and new-chapter badges that surface where you left off.
Build the SQLite schema and CRUD functions
Your library needs a persistent home. A SQLite database stored at data/library.db gives you structured storage without an external server or subscription.
- Create a new file called src/database.js in your manga-librarian project folder.
- Add the database initialization and table schema by pasting this code:
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
// Ensure the data directory exists
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, 'library.db');
// Initialize SQLite with WAL mode for better concurrent reads
export const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// Library table tracks each title through its reading lifecycle
db.exec(`
CREATE TABLE IF NOT EXISTS library (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anilist_id INTEGER,
mangadex_id TEXT,
title TEXT NOT NULL,
origin TEXT NOT NULL,
genres TEXT,
status TEXT NOT NULL DEFAULT 'bucket_list',
current_chapter INTEGER DEFAULT 0,
total_chapters INTEGER,
rating INTEGER,
notes TEXT,
legal_link TEXT,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
last_visited_at TEXT
)
`);
// Custom lists let you organize titles into named genre shelves
db.exec(`
CREATE TABLE IF NOT EXISTS custom_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Junction table linking custom lists to library entries
db.exec(`
CREATE TABLE IF NOT EXISTS list_entries (
list_id INTEGER NOT NULL,
library_id INTEGER NOT NULL,
PRIMARY KEY (list_id, library_id),
FOREIGN KEY (list_id) REFERENCES custom_lists(id),
FOREIGN KEY (library_id) REFERENCES library(id)
)
`);
What does this code do?
The library table holds every title you track. The status column accepts six values: bucket_list, reading, completed, on_hold, dropped, and re_reading.
The custom_lists and list_entries tables form a many-to-many relationship so a single title can belong to multiple genre shelves.
- Below the schema code in the same file, add the CRUD write functions:
// Add a new title to the library (defaults to bucket_list status)
export function addTitle({ anilist_id, mangadex_id, title, origin, genres, legal_link, total_chapters }) {
const stmt = db.prepare(`
INSERT INTO library (anilist_id, mangadex_id, title, origin, genres, legal_link, total_chapters)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
anilist_id || null,
mangadex_id || null,
title,
origin,
genres ? JSON.stringify(genres) : null,
legal_link || null,
total_chapters || null
);
return result.lastInsertRowid;
}
// Update a title's reading status with validation
export function updateStatus(id, status) {
const validStatuses = ['bucket_list', 'reading', 'completed', 'on_hold', 'dropped', 're_reading'];
if (!validStatuses.includes(status)) {
throw new Error(`Invalid status: ${status}`);
}
db.prepare(`UPDATE library SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(status, id);
}
// Advance chapter progress for a title
export function updateProgress(id, chapter) {
db.prepare(`UPDATE library SET current_chapter = ?, updated_at = datetime('now') WHERE id = ?`).run(chapter, id);
}
// Set a rating between 1 and 10
export function setRating(id, rating) {
if (rating < 1 || rating > 10) throw new Error('Rating must be between 1 and 10');
db.prepare(`UPDATE library SET rating = ?, updated_at = datetime('now') WHERE id = ?`).run(rating, id);
}
// Append a note to a title (preserves existing notes)
export function addNote(id, note) {
const existing = db.prepare(`SELECT notes FROM library WHERE id = ?`).get(id);
const updated = existing?.notes ? `${existing.notes}\n${note}` : note;
db.prepare(`UPDATE library SET notes = ?, updated_at = datetime('now') WHERE id = ?`).run(updated, id);
}
What does this code do?
Each function handles one write operation. The addTitle function inserts a new entry with bucket_list as the default status. The updateStatus function validates against the six allowed statuses before writing.
The addNote function appends new notes to existing ones with a newline separator, so you build a reading journal over time.
- Below the write functions, add the query and export functions to complete the module:
// Get all titles with a specific status
export function getByStatus(status) {
return db.prepare(`SELECT * FROM library WHERE status = ? ORDER BY updated_at DESC`).all(status);
}
// Get all titles from a specific origin (JP, KR, or CN)
export function getByOrigin(origin) {
return db.prepare(`SELECT * FROM library WHERE origin = ? ORDER BY updated_at DESC`).all(origin);
}
// Get titles matching a genre (searches within the JSON genres array)
export function getByGenre(genre) {
return db.prepare(`SELECT * FROM library WHERE genres LIKE ? ORDER BY updated_at DESC`).all(`%${genre}%`);
}
// Get titles updated within the last 7 days
export function getRecentlyUpdated() {
return db.prepare(`SELECT * FROM library WHERE updated_at > datetime('now', '-7 days') ORDER BY updated_at DESC`).all();
}
// Export the entire library as a JSON-serializable object
export function exportAll() {
const library = db.prepare(`SELECT * FROM library ORDER BY added_at DESC`).all();
const lists = db.prepare(`SELECT * FROM custom_lists`).all();
const entries = db.prepare(`SELECT * FROM list_entries`).all();
return { library, custom_lists: lists, list_entries: entries };
}
What does this code do?
The query functions filter by status, origin, or genre and always sort by most recently updated. The getByGenre function uses a LIKE query against the stored JSON string of genres.
The exportAll function dumps every table into one JSON object. This is the data the learner owns and can move anywhere.
- Save src/database.js.
- Confirm the database file gets created by running this in your terminal:
node -e "import('./src/database.js').then(() => console.log('Database created'))"
- You should see Database created printed and a new data/library.db file in your project folder.
Seeing a module or import error?
Make sure your package.json includes "type": "module" at the top level. Without it, Node.js treats files as CommonJS and cannot use import statements.
If you see an error about better-sqlite3 not being found, run npm install again to ensure native bindings are compiled.
help me debug this database initialization error
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/database.js before moving on.
ⓧ I'd like to double check the full code
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
// Ensure the data directory exists
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, 'library.db');
// Initialize SQLite with WAL mode for better concurrent reads
export const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// Library table tracks each title through its reading lifecycle
db.exec(`
CREATE TABLE IF NOT EXISTS library (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anilist_id INTEGER,
mangadex_id TEXT,
title TEXT NOT NULL,
origin TEXT NOT NULL,
genres TEXT,
status TEXT NOT NULL DEFAULT 'bucket_list',
current_chapter INTEGER DEFAULT 0,
total_chapters INTEGER,
rating INTEGER,
notes TEXT,
legal_link TEXT,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
last_visited_at TEXT
)
`);
// Custom lists let you organize titles into named genre shelves
db.exec(`
CREATE TABLE IF NOT EXISTS custom_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Junction table linking custom lists to library entries
db.exec(`
CREATE TABLE IF NOT EXISTS list_entries (
list_id INTEGER NOT NULL,
library_id INTEGER NOT NULL,
PRIMARY KEY (list_id, library_id),
FOREIGN KEY (list_id) REFERENCES custom_lists(id),
FOREIGN KEY (library_id) REFERENCES library(id)
)
`);
// Add a new title to the library (defaults to bucket_list status)
export function addTitle({ anilist_id, mangadex_id, title, origin, genres, legal_link, total_chapters }) {
const stmt = db.prepare(`
INSERT INTO library (anilist_id, mangadex_id, title, origin, genres, legal_link, total_chapters)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
anilist_id || null,
mangadex_id || null,
title,
origin,
genres ? JSON.stringify(genres) : null,
legal_link || null,
total_chapters || null
);
return result.lastInsertRowid;
}
// Update a title's reading status with validation
export function updateStatus(id, status) {
const validStatuses = ['bucket_list', 'reading', 'completed', 'on_hold', 'dropped', 're_reading'];
if (!validStatuses.includes(status)) {
throw new Error(`Invalid status: ${status}`);
}
db.prepare(`UPDATE library SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(status, id);
}
// Advance chapter progress for a title
export function updateProgress(id, chapter) {
db.prepare(`UPDATE library SET current_chapter = ?, updated_at = datetime('now') WHERE id = ?`).run(chapter, id);
}
// Set a rating between 1 and 10
export function setRating(id, rating) {
if (rating < 1 || rating > 10) throw new Error('Rating must be between 1 and 10');
db.prepare(`UPDATE library SET rating = ?, updated_at = datetime('now') WHERE id = ?`).run(rating, id);
}
// Append a note to a title (preserves existing notes)
export function addNote(id, note) {
const existing = db.prepare(`SELECT notes FROM library WHERE id = ?`).get(id);
const updated = existing?.notes ? `${existing.notes}\n${note}` : note;
db.prepare(`UPDATE library SET notes = ?, updated_at = datetime('now') WHERE id = ?`).run(updated, id);
}
// Get all titles with a specific status
export function getByStatus(status) {
return db.prepare(`SELECT * FROM library WHERE status = ? ORDER BY updated_at DESC`).all(status);
}
// Get all titles from a specific origin (JP, KR, or CN)
export function getByOrigin(origin) {
return db.prepare(`SELECT * FROM library WHERE origin = ? ORDER BY updated_at DESC`).all(origin);
}
// Get titles matching a genre (searches within the JSON genres array)
export function getByGenre(genre) {
return db.prepare(`SELECT * FROM library WHERE genres LIKE ? ORDER BY updated_at DESC`).all(`%${genre}%`);
}
// Get titles updated within the last 7 days
export function getRecentlyUpdated() {
return db.prepare(`SELECT * FROM library WHERE updated_at > datetime('now', '-7 days') ORDER BY updated_at DESC`).all();
}
// Export the entire library as a JSON-serializable object
export function exportAll() {
const library = db.prepare(`SELECT * FROM library ORDER BY added_at DESC`).all();
const lists = db.prepare(`SELECT * FROM custom_lists`).all();
const entries = db.prepare(`SELECT * FROM list_entries`).all();
return { library, custom_lists: lists, list_entries: entries };
}
Register library MCP tools
With the database layer in place, you need to expose it to Claude. Each CRUD function becomes an MCP tool that Claude can call through natural conversation.
- Create a new file called src/library-tools.js in your project.
- Add the core lifecycle tools by pasting this code:
import { z } from 'zod';
import * as database from './database.js';
// Register all library management tools with the MCP server
export function registerLibraryTools(server) {
// Add a discovered title to the bucket list
server.registerTool('library_add', {
description: 'Add a title to the library bucket list from discovery results',
inputSchema: {
title: z.string(),
origin: z.enum(['JP', 'KR', 'CN']),
anilist_id: z.number().optional(),
mangadex_id: z.string().optional(),
genres: z.array(z.string()).optional(),
legal_link: z.string().optional(),
total_chapters: z.number().optional()
}
}, async ({ title, origin, anilist_id, mangadex_id, genres, legal_link, total_chapters }) => {
const id = database.addTitle({ anilist_id, mangadex_id, title, origin, genres, legal_link, total_chapters });
return { content: [{ type: 'text', text: `Added "${title}" to bucket list (ID: ${id})` }] };
});
// Move a bucket-list title to reading status
server.registerTool('library_checkin', {
description: 'Start reading a title (move from bucket list to reading)',
inputSchema: { id: z.number() }
}, async ({ id }) => {
database.updateStatus(id, 'reading');
return { content: [{ type: 'text', text: `Title ${id} checked in - now reading` }] };
});
// Increment chapter progress
server.registerTool('library_progress', {
description: 'Update current chapter progress for a reading title',
inputSchema: { id: z.number(), chapter: z.number() }
}, async ({ id, chapter }) => {
database.updateProgress(id, chapter);
return { content: [{ type: 'text', text: `Progress updated: now on chapter ${chapter}` }] };
});
// Mark a title as completed
server.registerTool('library_complete', {
description: 'Mark a title as completed',
inputSchema: { id: z.number() }
}, async ({ id }) => {
database.updateStatus(id, 'completed');
return { content: [{ type: 'text', text: `Title ${id} marked as completed` }] };
});
// Set a title to re-reading and reset progress to zero
server.registerTool('library_reread', {
description: 'Start re-reading a title (resets chapter progress to 0)',
inputSchema: { id: z.number() }
}, async ({ id }) => {
database.updateStatus(id, 're_reading');
database.updateProgress(id, 0);
return { content: [{ type: 'text', text: `Title ${id} set to re-reading, progress reset to 0` }] };
});
// Move to on_hold or dropped
server.registerTool('library_status', {
description: 'Change a title status to on_hold or dropped',
inputSchema: { id: z.number(), status: z.enum(['on_hold', 'dropped']) }
}, async ({ id, status }) => {
database.updateStatus(id, status);
return { content: [{ type: 'text', text: `Title ${id} moved to ${status}` }] };
});
What does this code do?
Each server.registerTool call exposes one database operation as a tool Claude can invoke. The inputSchema uses Zod validators so the MCP SDK automatically rejects malformed requests before your handler runs.
The lifecycle flows from library_add (bucket list) through library_checkin (reading) to library_complete or library_reread. At any point, library_status lets you pause (on_hold) or abandon (dropped) a title.
- Below the lifecycle tools in the same file, add the rating, listing, and export tools:
// Rate a title from 1-10 with an optional note
server.registerTool('library_rate', {
description: 'Rate a title from 1-10 with an optional note',
inputSchema: {
id: z.number(),
rating: z.number().min(1).max(10),
note: z.string().optional()
}
}, async ({ id, rating, note }) => {
database.setRating(id, rating);
if (note) database.addNote(id, note);
return { content: [{ type: 'text', text: `Rated title ${id}: ${rating}/10${note ? ' with note' : ''}` }] };
});
// List titles filtered by status, origin, genre, or recent activity
server.registerTool('library_list', {
description: 'List library titles filtered by status, origin, genre, or recent updates',
inputSchema: {
filter_type: z.enum(['status', 'origin', 'genre', 'recent']),
filter_value: z.string().optional()
}
}, async ({ filter_type, filter_value }) => {
let results;
switch (filter_type) {
case 'status': results = database.getByStatus(filter_value); break;
case 'origin': results = database.getByOrigin(filter_value); break;
case 'genre': results = database.getByGenre(filter_value); break;
case 'recent': results = database.getRecentlyUpdated(); break;
}
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
});
// Export the full library to a portable JSON file
server.registerTool('library_export', {
description: 'Export the entire library to data/export.json',
inputSchema: {}
}, async () => {
const fs = await import('fs');
const exportPath = path.join(__dirname, '..', 'data', 'export.json');
const data = database.exportAll();
fs.writeFileSync(exportPath, JSON.stringify(data, null, 2));
return { content: [{ type: 'text', text: `Library exported to data/export.json (${data.library.length} titles)` }] };
});
What does this code do?
The library_rate tool validates the rating is between 1 and 10 and optionally appends a note. The library_list tool routes to the correct query function based on the filter type.
The library_export tool writes the full database to data/export.json. This is the file you own and can take anywhere.
- Save src/library-tools.js. Do not close the file yet. You will add two more tools in the next substep.
Seeing a syntax error on save?
The function registerLibraryTools is intentionally left open (no closing brace yet). You will close it in the next substep after adding the continue-reading tools. Your editor may flag this until then.
help me fix a syntax issue in my library-tools.js file
Add continue-reading and new-chapter badges
Two more tools complete the library experience. One surfaces where you left off reading. The other checks MangaDex for new chapters since your last visit and flags titles that have updates waiting.
- In src/library-tools.js, add the continue-reading and new-badges tools below the library_export tool, then close the function:
// Return titles currently being read, sorted by most recent progress
server.registerTool('library_continue', {
description: 'Get titles you are currently reading, sorted by most recent progress',
inputSchema: {}
}, async () => {
const reading = database.getByStatus('reading');
// Sort by updated_at descending so the most recently touched title is first
reading.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
// Update last_visited_at for each queried title
for (const title of reading) {
database.db.prepare(`UPDATE library SET last_visited_at = datetime('now') WHERE id = ?`).run(title.id);
}
return { content: [{ type: 'text', text: JSON.stringify(reading, null, 2) }] };
});
// Check for new chapters since last visit and flag titles with updates
server.registerTool('library_new_badges', {
description: 'Flag titles with new chapters available since your last visit',
inputSchema: {}
}, async () => {
const { fetchMangaDex } = await import('./fetchers.js');
const reading = database.getByStatus('reading');
const badges = [];
for (const title of reading) {
if (!title.mangadex_id) continue;
// Query MangaDex for this title's latest update
const response = await fetchMangaDex({
ids: [title.mangadex_id],
'order[latestUploadedChapter]': 'desc'
});
if (response?.data?.[0]) {
const latestUpdate = response.data[0].attributes?.updatedAt;
// Flag if MangaDex shows an update newer than our last visit
if (title.last_visited_at && new Date(latestUpdate) > new Date(title.last_visited_at)) {
badges.push({ id: title.id, title: title.title, new_since: title.last_visited_at });
}
}
}
// Update last_visited_at for all checked titles
for (const title of reading) {
database.db.prepare(`UPDATE library SET last_visited_at = datetime('now') WHERE id = ?`).run(title.id);
}
const message = badges.length > 0
? `New chapters available:\n${JSON.stringify(badges, null, 2)}`
: 'No new chapters since your last visit';
return { content: [{ type: 'text', text: message }] };
});
} // Close registerLibraryTools
What does this code do?
The library_continue tool returns your in-progress titles sorted by most recent activity. It also stamps last_visited_at so the badges tool knows when you last checked.
The library_new_badges tool loops through reading titles that have a mangadex_id, queries MangaDex for the latest chapter upload date, and compares it against your last_visited_at timestamp. Any title with newer content gets flagged.
- Save src/library-tools.js.
✔️ Awesome, I've got everything!
Great. Make sure the closing brace and the // Close registerLibraryTools comment are at the bottom of your file.
ⓧ I'd like to double check the full code
import { z } from 'zod';
import * as database from './database.js';
// Register all library management tools with the MCP server
export function registerLibraryTools(server) {
// Add a discovered title to the bucket list
server.registerTool('library_add', {
description: 'Add a title to the library bucket list from discovery results',
inputSchema: {
title: z.string(),
origin: z.enum(['JP', 'KR', 'CN']),
anilist_id: z.number().optional(),
mangadex_id: z.string().optional(),
genres: z.array(z.string()).optional(),
legal_link: z.string().optional(),
total_chapters: z.number().optional()
}
}, async ({ title, origin, anilist_id, mangadex_id, genres, legal_link, total_chapters }) => {
const id = database.addTitle({ anilist_id, mangadex_id, title, origin, genres, legal_link, total_chapters });
return { content: [{ type: 'text', text: `Added "${title}" to bucket list (ID: ${id})` }] };
});
// Move a bucket-list title to reading status
server.registerTool('library_checkin', {
description: 'Start reading a title (move from bucket list to reading)',
inputSchema: { id: z.number() }
}, async ({ id }) => {
database.updateStatus(id, 'reading');
return { content: [{ type: 'text', text: `Title ${id} checked in - now reading` }] };
});
// Increment chapter progress
server.registerTool('library_progress', {
description: 'Update current chapter progress for a reading title',
inputSchema: { id: z.number(), chapter: z.number() }
}, async ({ id, chapter }) => {
database.updateProgress(id, chapter);
return { content: [{ type: 'text', text: `Progress updated: now on chapter ${chapter}` }] };
});
// Mark a title as completed
server.registerTool('library_complete', {
description: 'Mark a title as completed',
inputSchema: { id: z.number() }
}, async ({ id }) => {
database.updateStatus(id, 'completed');
return { content: [{ type: 'text', text: `Title ${id} marked as completed` }] };
});
// Set a title to re-reading and reset progress to zero
server.registerTool('library_reread', {
description: 'Start re-reading a title (resets chapter progress to 0)',
inputSchema: { id: z.number() }
}, async ({ id }) => {
database.updateStatus(id, 're_reading');
database.updateProgress(id, 0);
return { content: [{ type: 'text', text: `Title ${id} set to re-reading, progress reset to 0` }] };
});
// Move to on_hold or dropped
server.registerTool('library_status', {
description: 'Change a title status to on_hold or dropped',
inputSchema: { id: z.number(), status: z.enum(['on_hold', 'dropped']) }
}, async ({ id, status }) => {
database.updateStatus(id, status);
return { content: [{ type: 'text', text: `Title ${id} moved to ${status}` }] };
});
// Rate a title from 1-10 with an optional note
server.registerTool('library_rate', {
description: 'Rate a title from 1-10 with an optional note',
inputSchema: {
id: z.number(),
rating: z.number().min(1).max(10),
note: z.string().optional()
}
}, async ({ id, rating, note }) => {
database.setRating(id, rating);
if (note) database.addNote(id, note);
return { content: [{ type: 'text', text: `Rated title ${id}: ${rating}/10${note ? ' with note' : ''}` }] };
});
// List titles filtered by status, origin, genre, or recent activity
server.registerTool('library_list', {
description: 'List library titles filtered by status, origin, genre, or recent updates',
inputSchema: {
filter_type: z.enum(['status', 'origin', 'genre', 'recent']),
filter_value: z.string().optional()
}
}, async ({ filter_type, filter_value }) => {
let results;
switch (filter_type) {
case 'status': results = database.getByStatus(filter_value); break;
case 'origin': results = database.getByOrigin(filter_value); break;
case 'genre': results = database.getByGenre(filter_value); break;
case 'recent': results = database.getRecentlyUpdated(); break;
}
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
});
// Export the full library to a portable JSON file
server.registerTool('library_export', {
description: 'Export the entire library to data/export.json',
inputSchema: {}
}, async () => {
const fs = await import('fs');
const exportPath = path.join(__dirname, '..', 'data', 'export.json');
const data = database.exportAll();
fs.writeFileSync(exportPath, JSON.stringify(data, null, 2));
return { content: [{ type: 'text', text: `Library exported to data/export.json (${data.library.length} titles)` }] };
});
// Return titles currently being read, sorted by most recent progress
server.registerTool('library_continue', {
description: 'Get titles you are currently reading, sorted by most recent progress',
inputSchema: {}
}, async () => {
const reading = database.getByStatus('reading');
// Sort by updated_at descending so the most recently touched title is first
reading.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
// Update last_visited_at for each queried title
for (const title of reading) {
database.db.prepare(`UPDATE library SET last_visited_at = datetime('now') WHERE id = ?`).run(title.id);
}
return { content: [{ type: 'text', text: JSON.stringify(reading, null, 2) }] };
});
// Check for new chapters since last visit and flag titles with updates
server.registerTool('library_new_badges', {
description: 'Flag titles with new chapters available since your last visit',
inputSchema: {}
}, async () => {
const { fetchMangaDex } = await import('./fetchers.js');
const reading = database.getByStatus('reading');
const badges = [];
for (const title of reading) {
if (!title.mangadex_id) continue;
// Query MangaDex for this title's latest update
const response = await fetchMangaDex({
ids: [title.mangadex_id],
'order[latestUploadedChapter]': 'desc'
});
if (response?.data?.[0]) {
const latestUpdate = response.data[0].attributes?.updatedAt;
// Flag if MangaDex shows an update newer than our last visit
if (title.last_visited_at && new Date(latestUpdate) > new Date(title.last_visited_at)) {
badges.push({ id: title.id, title: title.title, new_since: title.last_visited_at });
}
}
}
// Update last_visited_at for all checked titles
for (const title of reading) {
database.db.prepare(`UPDATE library SET last_visited_at = datetime('now') WHERE id = ?`).run(title.id);
}
const message = badges.length > 0
? `New chapters available:\n${JSON.stringify(badges, null, 2)}`
: 'No new chapters since your last visit';
return { content: [{ type: 'text', text: message }] };
});
} // Close registerLibraryTools
Now you need to wire the library tools into your MCP server so Claude can call them.
- In src/index.js, find the existing import block at the top of the file.
- Add this import line alongside your existing discovery imports:
import { registerLibraryTools } from './library-tools.js';
- Find the section where you register discovery tools (after server.connect or after the discovery tool registrations).
- Add this line to register the library tools with the same server instance:
// Register library management tools
registerLibraryTools(server);
- Save src/index.js.
- Restart Claude Desktop to reload the MCP server with the new tools.
- Click the Add files, connectors, and more plus icon in the chat input, then hover over the Connectors menu. You should see the library tools (library_add, library_checkin, etc.) listed alongside the discovery tools from earlier.
Library tools not appearing in Claude Desktop?
Check the MCP logs at %APPDATA%\Claude\logs for any startup errors. A common issue is a missing import path or a typo in the module name.
Make sure src/library-tools.js exports the registerLibraryTools function and that src/index.js calls it with the server instance.
help me debug why my library tools are not showing up in Claude Desktop
Your private library is live. Claude can now add titles, track your reading progress, rate completions, and export everything you own to a portable JSON file. Next up, you will build a daily digest that surfaces new chapters and releases each morning.
Set Up the Daily Digest and Scheduler
Your library now tracks every title from bucket list through completion. The next piece of the puzzle is making discovery automatic.
Instead of opening Claude each morning and manually asking for updates, you will build a digest generator that checks for new chapters on your followed titles and surfaces fresh releases matching your taste. A scheduled task runs it daily so the digest is ready before you even ask.
In this step, get ready to:
- Build a digest generator that checks for new chapters and releases grouped by origin.
- Create a standalone runner script that writes the digest to JSON and text files.
- Register a Windows Task Scheduler entry that runs the digest daily at 07:00.
Build the digest generator
The digest generator pulls together three signals: new chapters on titles you are reading, new releases matching your genres and origins, and a nudge to continue your longest-paused title. It groups everything by origin so you can scan Manga, Manhwa, and Manhua at a glance.
- Create a new file called src/digest.js inside your manga-librarian folder.
- Add the imports, timestamp helpers, and the new-chapters fetcher by pasting this code:
const { fetchMangaDex, fetchAniList } = require('./fetchers');
const { getByStatus } = require('./database');
const fs = require('fs');
const path = require('path');
// Path to store the last-run timestamp
const LAST_RUN_PATH = path.join(__dirname, '..', 'data', 'digest-last-run.json');
// Read the timestamp of the previous digest run
function getLastRunTimestamp() {
try {
const data = JSON.parse(fs.readFileSync(LAST_RUN_PATH, 'utf-8'));
return new Date(data.lastRun);
} catch {
// Default to 24 hours ago if no previous run exists
return new Date(Date.now() - 24 * 60 * 60 * 1000);
}
}
// Write the current time as the last-run marker
function updateLastRunTimestamp() {
fs.writeFileSync(LAST_RUN_PATH, JSON.stringify({ lastRun: new Date().toISOString() }));
}
// Check MangaDex for new chapters on followed titles since the last run
async function getNewChapters(readingTitles, since) {
const results = [];
for (const title of readingTitles) {
if (!title.mangadex_id) continue;
try {
const data = await fetchMangaDex({
endpoint: `/manga/${title.mangadex_id}/feed`,
params: {
'order[chapter]': 'desc',
'translatedLanguage[]': 'en',
'createdAtSince': since.toISOString().slice(0, 19),
limit: 5
}
});
if (data && data.data && data.data.length > 0) {
results.push({
title: title.title,
origin: title.origin,
chapters: data.data.map(ch => ({
chapter: ch.attributes.chapter,
title: ch.attributes.title
})),
legal_link: title.legal_link
});
}
} catch (err) {
// Skip titles whose chapter feed fails to fetch
}
}
return results;
}
What does this code do?
The timestamp helpers read and write a small JSON file that records when the digest last ran. This lets the generator only fetch chapters published after that point.
The getNewChapters function loops through every title with status "reading" and calls the MangaDex chapter feed endpoint filtered to English translations created after the last run. It collects the chapter numbers and the title's legal link for the output.
- Below that code, add the new-releases fetcher and the main generation logic:
// Check AniList for new releases matching the client's genres and origins
async function getNewReleases(genres, origins, since) {
const results = [];
const query = `
query ($page: Int, $countryOfOrigin: CountryCode, $genres: [String], $startDate: FuzzyDateInt) {
Page(page: $page, perPage: 10) {
media(type: MANGA, countryOfOrigin: $countryOfOrigin, genre_in: $genres, startDate_greater: $startDate, sort: POPULARITY_DESC) {
id
title { english romaji }
genres
description
averageScore
}
}
}
`;
for (const origin of origins) {
const variables = {
page: 1,
countryOfOrigin: origin,
genres: genres.slice(0, 3),
startDate: parseInt(since.toISOString().slice(0, 10).replace(/-/g, ''))
};
try {
const data = await fetchAniList(query, variables);
if (data && data.data && data.data.Page && data.data.Page.media) {
for (const media of data.data.Page.media) {
results.push({
title: media.title.english || media.title.romaji,
origin: origin,
genres: media.genres,
overview: media.description
? media.description.replace(/<[^>]+>/g, '').slice(0, 200)
: 'No description available.',
score: media.averageScore
});
}
}
} catch (err) {
// Skip origins that fail to fetch
}
}
return results;
}
// Find the reading title with the oldest progress update
function getLongestPaused(readingTitles) {
if (readingTitles.length === 0) return null;
return readingTitles.sort((a, b) =>
new Date(a.updated_at) - new Date(b.updated_at)
)[0];
}
// Map origin codes to display labels
const ORIGIN_LABELS = { JP: 'Manga', KR: 'Manhwa', CN: 'Manhua' };
// Assemble the full digest object
async function generateDigest() {
const lastRun = getLastRunTimestamp();
const readingTitles = getByStatus('reading');
// Collect unique genres and origins from the reading list
const genres = [...new Set(readingTitles.flatMap(t => JSON.parse(t.genres || '[]')))];
const origins = [...new Set(readingTitles.map(t => t.origin))];
// Fetch new chapters and releases
const newChapters = await getNewChapters(readingTitles, lastRun);
const newReleases = await getNewReleases(
genres,
origins.length > 0 ? origins : ['JP', 'KR', 'CN'],
lastRun
);
const longestPaused = getLongestPaused(readingTitles);
// Group results by origin
const digest = { generatedAt: new Date().toISOString(), origins: {} };
for (const origin of ['JP', 'KR', 'CN']) {
digest.origins[ORIGIN_LABELS[origin]] = {
newChapters: newChapters.filter(c => c.origin === origin),
newReleases: newReleases.filter(r => r.origin === origin)
};
}
// Add the continue-reading prompt
digest.continueReading = longestPaused
? { title: longestPaused.title, origin: longestPaused.origin, currentChapter: longestPaused.current_chapter }
: null;
return digest;
}
What does this code do?
The getNewReleases function queries AniList for recently started series that share genres with your reading list, filtered per origin. It converts the AniList FuzzyDateInt format from the last-run timestamp.
The generateDigest function orchestrates everything: it reads your library for "reading" titles, calls both fetchers, groups the results into Manga/Manhwa/Manhua buckets, and attaches a continue-reading nudge for the title you have not touched the longest.
- Finally, add the text formatter and the module exports at the bottom of the file:
// Format the digest object as human-readable text
function formatDigestText(digest) {
let text = `=== Manga Librarian Daily Digest ===\n`;
text += `Generated: ${digest.generatedAt}\n\n`;
for (const [label, data] of Object.entries(digest.origins)) {
text += `--- ${label} ---\n`;
if (data.newChapters.length > 0) {
text += `\nNew Chapters:\n`;
for (const item of data.newChapters) {
const chNums = item.chapters.map(c => `Ch. ${c.chapter}`).join(', ');
text += ` ${item.title} - ${chNums}`;
text += item.legal_link ? ` (${item.legal_link})\n` : ` (no verified source)\n`;
}
}
if (data.newReleases.length > 0) {
text += `\nNew Releases:\n`;
for (const item of data.newReleases) {
text += ` ${item.title} (Score: ${item.score || 'N/A'})\n`;
text += ` ${item.overview.slice(0, 100)}...\n`;
}
}
if (data.newChapters.length === 0 && data.newReleases.length === 0) {
text += ` No updates since last digest.\n`;
}
text += `\n`;
}
if (digest.continueReading) {
text += `--- Continue Reading ---\n`;
text += ` Pick up: ${digest.continueReading.title} (${ORIGIN_LABELS[digest.continueReading.origin] || digest.continueReading.origin}) at Chapter ${digest.continueReading.currentChapter}\n`;
}
return text;
}
module.exports = { generateDigest, formatDigestText, updateLastRunTimestamp };
What does this code do?
The formatDigestText function turns the structured digest object into a scannable plain-text format grouped by origin. Each origin section lists new chapters (with legal links) and new releases (with scores). The "Continue Reading" section nudges you toward your most-neglected title.
The module exports three functions: generateDigest for the main assembly, formatDigestText for the human-readable output, and updateLastRunTimestamp for marking a successful run.
- Save src/digest.js.
Seeing a module not found error?
Make sure the require paths match your project structure. The ./fetchers and ./database imports expect those files to live in the same src/ folder as digest.js.
help me debug a module not found error in my digest.js file
Build the digest runner script
The runner is a standalone Node script that the scheduler invokes. It calls the digest generator, writes both a JSON file and a human-readable text file to the data/ folder, and stamps the last-run time so the next run only fetches what is new.
- Create a new file called src/run-digest.js and paste this code:
const fs = require('fs');
const path = require('path');
const { generateDigest, formatDigestText, updateLastRunTimestamp } = require('./digest');
// Output directory for digest files
const DATA_DIR = path.join(__dirname, '..', 'data');
async function runDigest() {
console.log('Running daily digest...');
try {
// Generate the digest from library and API data
const digest = await generateDigest();
// Write structured JSON output
const jsonPath = path.join(DATA_DIR, 'digest-latest.json');
fs.writeFileSync(jsonPath, JSON.stringify(digest, null, 2));
console.log(`Written: ${jsonPath}`);
// Write human-readable text output
const textPath = path.join(DATA_DIR, 'digest-latest.txt');
const text = formatDigestText(digest);
fs.writeFileSync(textPath, text);
console.log(`Written: ${textPath}`);
// Mark this run so the next digest only fetches new data
updateLastRunTimestamp();
console.log('Digest complete. Last-run timestamp updated.');
} catch (err) {
console.error('Digest failed:', err.message);
process.exit(1);
}
}
runDigest();
What does this code do?
The script calls generateDigest() to assemble the full digest, then writes two output files: data/digest-latest.json for structured data and data/digest-latest.txt for a scannable text version.
If anything fails, the script exits with code 1 so the scheduler can detect the failure. On success it updates the last-run timestamp, ensuring the next digest only surfaces content published after this moment.
- Save src/run-digest.js.
- Test the runner by running this command from your manga-librarian folder:
node src/run-digest.js
- You should see three log lines confirming the JSON path, text path, and timestamp update. Check that data/digest-latest.json and data/digest-latest.txt now exist.
Seeing "Digest failed" in the output?
If the error mentions a network issue, make sure your machine has internet access. The digest fetches live data from MangaDex and AniList.
If your library has no "reading" titles yet, the digest will still succeed but produce empty sections. That is expected at this stage.
help me debug why my run-digest.js script is failing
Register the scheduled task and expose the digest tool
Windows Task Scheduler can run your digest script every morning without you lifting a finger. You will create a task that fires at 07:00 daily and catches up if the machine was asleep at that time.
- Open PowerShell as Administrator by pressing the Windows key, typing PowerShell, right-clicking Windows PowerShell, and selecting Run as administrator.
- Create the scheduled task by running this command:
schtasks /create /tn "MangaLibrarianDigest" /tr "node [[MANGA_LIBRARIAN_PATH="C:\Users\YourName\Desktop\manga-librarian"]]\src\run-digest.js" /sc daily /st 07:00 /rl HIGHEST /f
What does this command do?
- /tn sets the task name to "MangaLibrarianDigest".
- /tr specifies the command to run (Node executing your runner script).
- /sc daily /st 07:00 schedules it for every day at 7 AM.
- /rl HIGHEST runs the task with elevated privileges.
- /f forces creation without a confirmation prompt.
- You should see a success message confirming the task was created.
The schtasks command does not support the "start when available" option directly. You need to enable it through the Task Scheduler GUI so the digest runs on wake if the machine missed the 07:00 trigger.
- Press the Windows key, type Task Scheduler, and press Enter to open it.
- In the left panel, click Task Scheduler Library.
- Find MangaLibrarianDigest in the task list and double-click it.
- Switch to the Settings tab.
- Check the box labeled Run task as soon as possible after a scheduled start is missed.
- Click OK to save.
Why enable the missed-start option?
If your machine is asleep or off at 07:00, the task will not fire on schedule. With this option enabled, Windows runs the task as soon as the machine wakes up. This ensures your digest is always ready by the time you open Claude Desktop.
Task creation failed?
Make sure you opened PowerShell as Administrator. The /rl HIGHEST flag requires elevated privileges.
Double-check that the path in the /tr flag points to the actual location of src/run-digest.js on your machine. Use the full absolute path with backslashes.
help me fix a schtasks error when creating my MangaLibrarianDigest scheduled task
The final piece is exposing a digest_today tool in the MCP server so you can also ask Claude "What's new today?" without waiting for the scheduler.
- In src/index.js, find the section where you register your other tools.
- Add the following digest_today tool registration below the existing tool registrations:
// Expose the latest digest as an MCP tool
server.registerTool('digest_today', {
description: 'Get today\'s digest of new chapters and releases',
inputSchema: {}
}, async () => {
const fs = require('fs');
const path = require('path');
const digestPath = path.join(__dirname, '..', 'data', 'digest-latest.json');
try {
const digest = JSON.parse(fs.readFileSync(digestPath, 'utf-8'));
const { formatDigestText } = require('./digest');
const text = formatDigestText(digest);
return { content: [{ type: 'text', text }] };
} catch (err) {
return { content: [{ type: 'text', text: 'No digest available yet. Run the digest script first or wait for the next scheduled run.' }] };
}
});
What does this tool do?
The digest_today tool reads the latest digest JSON from disk and formats it as text for Claude to display. If no digest exists yet, it returns a friendly message instead of crashing.
This means you can ask Claude "What's new today?" at any time. If the scheduler already ran, you get instant results. If not, you can manually run node src/run-digest.js to generate a fresh one.
- Save src/index.js.
- Restart Claude Desktop so it picks up the new digest_today tool.
- Click the plus icon in the chat input area, hover over Connectors, and confirm you see digest_today listed among the available tools.
Don't see digest_today in the tools list?
Make sure you saved src/index.js and fully restarted Claude Desktop (quit and reopen, not just close the window).
Check the MCP logs at %APPDATA%\Claude\logs for any syntax errors in your server file.
help me figure out why my digest_today tool is not showing up in Claude Desktop
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/digest.js and src/run-digest.js, and that the digest_today tool appears in Claude Desktop.
ⓧ I'd like to double check the full code
Here is the complete src/digest.js:
const { fetchMangaDex, fetchAniList } = require('./fetchers');
const { getByStatus } = require('./database');
const fs = require('fs');
const path = require('path');
// Path to store the last-run timestamp
const LAST_RUN_PATH = path.join(__dirname, '..', 'data', 'digest-last-run.json');
// Read the timestamp of the previous digest run
function getLastRunTimestamp() {
try {
const data = JSON.parse(fs.readFileSync(LAST_RUN_PATH, 'utf-8'));
return new Date(data.lastRun);
} catch {
// Default to 24 hours ago if no previous run exists
return new Date(Date.now() - 24 * 60 * 60 * 1000);
}
}
// Write the current time as the last-run marker
function updateLastRunTimestamp() {
fs.writeFileSync(LAST_RUN_PATH, JSON.stringify({ lastRun: new Date().toISOString() }));
}
// Check MangaDex for new chapters on followed titles since the last run
async function getNewChapters(readingTitles, since) {
const results = [];
for (const title of readingTitles) {
if (!title.mangadex_id) continue;
try {
const data = await fetchMangaDex({
endpoint: `/manga/${title.mangadex_id}/feed`,
params: {
'order[chapter]': 'desc',
'translatedLanguage[]': 'en',
'createdAtSince': since.toISOString().slice(0, 19),
limit: 5
}
});
if (data && data.data && data.data.length > 0) {
results.push({
title: title.title,
origin: title.origin,
chapters: data.data.map(ch => ({
chapter: ch.attributes.chapter,
title: ch.attributes.title
})),
legal_link: title.legal_link
});
}
} catch (err) {
// Skip titles whose chapter feed fails to fetch
}
}
return results;
}
// Check AniList for new releases matching the client's genres and origins
async function getNewReleases(genres, origins, since) {
const results = [];
const query = `
query ($page: Int, $countryOfOrigin: CountryCode, $genres: [String], $startDate: FuzzyDateInt) {
Page(page: $page, perPage: 10) {
media(type: MANGA, countryOfOrigin: $countryOfOrigin, genre_in: $genres, startDate_greater: $startDate, sort: POPULARITY_DESC) {
id
title { english romaji }
genres
description
averageScore
}
}
}
`;
for (const origin of origins) {
const variables = {
page: 1,
countryOfOrigin: origin,
genres: genres.slice(0, 3),
startDate: parseInt(since.toISOString().slice(0, 10).replace(/-/g, ''))
};
try {
const data = await fetchAniList(query, variables);
if (data && data.data && data.data.Page && data.data.Page.media) {
for (const media of data.data.Page.media) {
results.push({
title: media.title.english || media.title.romaji,
origin: origin,
genres: media.genres,
overview: media.description
? media.description.replace(/<[^>]+>/g, '').slice(0, 200)
: 'No description available.',
score: media.averageScore
});
}
}
} catch (err) {
// Skip origins that fail to fetch
}
}
return results;
}
// Find the reading title with the oldest progress update
function getLongestPaused(readingTitles) {
if (readingTitles.length === 0) return null;
return readingTitles.sort((a, b) =>
new Date(a.updated_at) - new Date(b.updated_at)
)[0];
}
// Map origin codes to display labels
const ORIGIN_LABELS = { JP: 'Manga', KR: 'Manhwa', CN: 'Manhua' };
// Assemble the full digest object
async function generateDigest() {
const lastRun = getLastRunTimestamp();
const readingTitles = getByStatus('reading');
// Collect unique genres and origins from the reading list
const genres = [...new Set(readingTitles.flatMap(t => JSON.parse(t.genres || '[]')))];
const origins = [...new Set(readingTitles.map(t => t.origin))];
// Fetch new chapters and releases
const newChapters = await getNewChapters(readingTitles, lastRun);
const newReleases = await getNewReleases(
genres,
origins.length > 0 ? origins : ['JP', 'KR', 'CN'],
lastRun
);
const longestPaused = getLongestPaused(readingTitles);
// Group results by origin
const digest = { generatedAt: new Date().toISOString(), origins: {} };
for (const origin of ['JP', 'KR', 'CN']) {
digest.origins[ORIGIN_LABELS[origin]] = {
newChapters: newChapters.filter(c => c.origin === origin),
newReleases: newReleases.filter(r => r.origin === origin)
};
}
// Add the continue-reading prompt
digest.continueReading = longestPaused
? { title: longestPaused.title, origin: longestPaused.origin, currentChapter: longestPaused.current_chapter }
: null;
return digest;
}
// Format the digest object as human-readable text
function formatDigestText(digest) {
let text = `=== Manga Librarian Daily Digest ===\n`;
text += `Generated: ${digest.generatedAt}\n\n`;
for (const [label, data] of Object.entries(digest.origins)) {
text += `--- ${label} ---\n`;
if (data.newChapters.length > 0) {
text += `\nNew Chapters:\n`;
for (const item of data.newChapters) {
const chNums = item.chapters.map(c => `Ch. ${c.chapter}`).join(', ');
text += ` ${item.title} - ${chNums}`;
text += item.legal_link ? ` (${item.legal_link})\n` : ` (no verified source)\n`;
}
}
if (data.newReleases.length > 0) {
text += `\nNew Releases:\n`;
for (const item of data.newReleases) {
text += ` ${item.title} (Score: ${item.score || 'N/A'})\n`;
text += ` ${item.overview.slice(0, 100)}...\n`;
}
}
if (data.newChapters.length === 0 && data.newReleases.length === 0) {
text += ` No updates since last digest.\n`;
}
text += `\n`;
}
if (digest.continueReading) {
text += `--- Continue Reading ---\n`;
text += ` Pick up: ${digest.continueReading.title} (${ORIGIN_LABELS[digest.continueReading.origin] || digest.continueReading.origin}) at Chapter ${digest.continueReading.currentChapter}\n`;
}
return text;
}
module.exports = { generateDigest, formatDigestText, updateLastRunTimestamp };
Here is the complete src/run-digest.js:
const fs = require('fs');
const path = require('path');
const { generateDigest, formatDigestText, updateLastRunTimestamp } = require('./digest');
// Output directory for digest files
const DATA_DIR = path.join(__dirname, '..', 'data');
async function runDigest() {
console.log('Running daily digest...');
try {
// Generate the digest from library and API data
const digest = await generateDigest();
// Write structured JSON output
const jsonPath = path.join(DATA_DIR, 'digest-latest.json');
fs.writeFileSync(jsonPath, JSON.stringify(digest, null, 2));
console.log(`Written: ${jsonPath}`);
// Write human-readable text output
const textPath = path.join(DATA_DIR, 'digest-latest.txt');
const text = formatDigestText(digest);
fs.writeFileSync(textPath, text);
console.log(`Written: ${textPath}`);
// Mark this run so the next digest only fetches new data
updateLastRunTimestamp();
console.log('Digest complete. Last-run timestamp updated.');
} catch (err) {
console.error('Digest failed:', err.message);
process.exit(1);
}
}
runDigest();
Here is the digest_today tool registration to add in src/index.js:
// Expose the latest digest as an MCP tool
server.registerTool('digest_today', {
description: 'Get today\'s digest of new chapters and releases',
inputSchema: {}
}, async () => {
const fs = require('fs');
const path = require('path');
const digestPath = path.join(__dirname, '..', 'data', 'digest-latest.json');
try {
const digest = JSON.parse(fs.readFileSync(digestPath, 'utf-8'));
const { formatDigestText } = require('./digest');
const text = formatDigestText(digest);
return { content: [{ type: 'text', text }] };
} catch (err) {
return { content: [{ type: 'text', text: 'No digest available yet. Run the digest script first or wait for the next scheduled run.' }] };
}
});
Your daily digest is wired up. Every morning the scheduler generates your personalized update, and you can ask Claude at any time for the latest. Next up, you will put the whole system through a proof-of-value run to confirm everything works end to end.
Run the Proof-of-Value Readout
Your MCP server has discovery tools, a private library, and a daily digest all wired up and running.
Now it's time to prove the system works end to end. In this step, you'll run every category tool across all three origins, exercise the full library lifecycle, and document the results in a one-page proof-of-value readout.
In this step, get ready to:
- Run a representative end-to-end discovery test across all three origins.
- Exercise the full library lifecycle from bucket list through re-reading.
- Write a proof-of-value readout documenting validation bars and adoption targets.
Run a representative end-to-end discovery test
A proof-of-value run is not a unit test. It is a structured walkthrough that exercises every major capability and records whether the output meets the project's validation bars.
You will invoke each category tool for all three origins and inspect the results right inside Claude Desktop.
- Open Claude Desktop and start a new conversation.
- Verify the MCP tools are connected by clicking the plus icon in the chat input and hovering over Connectors to see your server listed.
- Ask Claude to show you popular manga (JP origin, trending). Confirm the response includes titles with origin, a spoiler-light overview, and either a verified legal link or an explicit abstention.
- Ask Claude for top manhwa (KR origin, sorted by all-time score). Verify the same fields appear.
- Ask Claude for new manhua releases (CN origin). Confirm brand-new series appear with creation dates.
- Ask Claude for completed manga in the action genre. Verify each result shows status as finished with a legal link or abstention.
- Ask Claude for new chapters across your followed titles. If your library is empty, Claude should indicate no followed titles exist yet.
What are you checking for?
Each result must pass three bars. First, it has a title, origin, and overview (the category bar). Second, any link points to a domain on your allowlist or the result explicitly abstains (the grounding bar). Third, no duplicate titles appear across sources (the dedup bar).
If a result links to a domain not in config/allowlist.json, that is a verification failure. Note it down for the readout.
- Note the total number of categories tested, titles surfaced per origin, links that verified successfully, and any that abstained.
Not seeing results from all three origins?
If a category returns empty results for one origin, run preflight again to check API reachability. AniList may rate-limit after rapid consecutive queries. Wait 60 seconds and retry.
If Claude does not call the MCP tools at all, check that your server appears in the Connectors menu. Restart Claude Desktop if it does not.
Check MCP logs for errors:
MCP logs are at %APPDATA%\Claude\logs.
Why aren't my MCP discovery tools returning results for all origins?
Exercise the full library lifecycle
The library must carry a title through every status transition: bucket list, reading, completed, and re-reading. You will walk through the full lifecycle in Claude Desktop, then export to confirm the data persisted correctly.
- In the same Claude Desktop conversation, ask Claude to add one of the titles from the discovery results to your bucket list.
- Ask Claude to check it in as reading.
- Ask Claude to advance progress (increment chapter) twice.
- Ask Claude to mark the title as completed.
- Pick a different title from the discovery results and ask Claude to add it, check it in, and set it to re-reading.
- Ask Claude to rate your completed title (a number from 1-10) with a short note.
- Ask Claude to export your library.
What does the export produce?
The library_export tool writes a full JSON dump of your library to data/export.json. Every entry should show the correct status: one title at completed with a rating and note, and another at re_reading with reset progress.
- Open data/export.json in your code editor. Confirm each entry has the correct status, chapter count, and any ratings or notes you added.
- Back in Claude Desktop, ask Claude to run the digest. Confirm the output is grouped by origin (Manga, Manhwa, Manhua) and that no links appear to domains outside your allowlist.
Library export missing entries?
If data/export.json is empty or missing entries, check that each Claude response confirmed the tool call succeeded. Look for error messages in Claude's reply indicating the tool returned an error.
Verify data/library.db exists and is not empty. If it has zero bytes, the database initialization in src/database.js may have a path issue.
Why is my library export missing titles I added through Claude?
Write the proof-of-value readout
The readout is a one-page document that records what you tested, what passed, and what the adoption path looks like. It captures the evidence from your end-to-end run so anyone (including future you) can see the system delivers on its promise.
- Create a new file called proof-of-value.md inside the data/ folder of your manga-librarian project.
- Copy the following template into the file, then fill in the values from your test run:
# Proof-of-Value Readout
## Discovery Test Results
- **Categories tested:** [count, e.g. 5]
- **Titles surfaced (Manga / JP):** [count]
- **Titles surfaced (Manhwa / KR):** [count]
- **Titles surfaced (Manhua / CN):** [count]
- **Links verified (resolved on allowlist):** [count]
- **Links abstained (no verified official source):** [count]
- **Hallucinated or duplicate titles caught and dropped:** [count]
## Library Lifecycle
- Added title to bucket list: Yes
- Checked in as reading: Yes
- Advanced progress (x2): Yes
- Marked completed: Yes
- Set to re-reading: Yes
- Rating and note saved: Yes
- Export to data/export.json: Verified
## Digest
- Digest generated: Yes
- Output grouped by origin (Manga/Manhwa/Manhua): Yes
- Hallucinated links in digest: None
## Validation Bars Passed
| Bar | Description | Status |
|-----|-------------|--------|
| Grounding | Every link passed through from API source and resolved via HEAD request | Pass |
| Category | Rankings sourced from AniList/MangaDex/Jikan, not invented | Pass |
| Library | Full lifecycle persists in local SQLite | Pass |
| Safety | No hosted content, no piracy links, abstains when no official source | Pass |
| Ownership | Local SQLite database, exportable JSON, no cloud dependency | Pass |
## Adoption Targets
| Milestone | Target |
|-----------|--------|
| Day 1 | Browse categories and add first titles to library |
| Day 30 | Daily digest habit established, no aggregator slip-through |
| Day 90 | Genre shelves tuned, first stories completed and rated |
| Day 180 | Default discovery tool, two-minute daily habit |
What does each validation bar mean?
Grounding confirms that every link came from an API response and resolved (HTTP 200-399) against the allowlist. Category confirms that rankings are from the source API, not invented by the model. Library confirms the full lifecycle persists across sessions in your local SQLite database.
Safety confirms no hosted content exists on your server and no links point to piracy sites. Ownership confirms your data lives locally in a SQLite file you can export to JSON at any time, with no cloud dependency.
- Replace each bracketed placeholder with the actual counts and results from your test run.
- If any validation bar did not pass (for example, a link resolved to a non-allowlisted domain), change its status to Fail and add a note explaining what happened.
- Save data/proof-of-value.md.
Keep the readout honest
The readout is most useful when it records failures alongside successes. If a title had no legal source and abstained, that is a success of the safety model. Record abstention counts as evidence the system works correctly.
- Confirm data/proof-of-value.md exists by running:
ls data/
You should see proof-of-value.md, export.json, library.db, digest-latest.json, and digest-latest.txt all listed in the output.
Missing files in the data folder?
If export.json is missing, re-run the library export by asking Claude to export your library. If digest-latest.json is missing, ask Claude to run the digest or invoke the digest runner directly:
node src/run-digest.js
My data folder is missing expected output files from the proof-of-value run.
Secret mission
Recommendations, Hidden Gems, and Adversarial Verify
Your librarian can browse and track. But can it recommend titles based on what you loved, surface hidden gems most readers miss, and actively red-team every link it serves you? Build a recommendation engine, a release calendar, and an adversarial verify pass that tries to break your own safety guarantees.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The manga-librarian project folder on your Desktop (contains all source code, database, and dependencies).
- The MangaLibrarianDigest scheduled task in Windows Task Scheduler (runs the daily digest at 07:00).
- The MCP server entry in claude_desktop_config.json (tells Claude Desktop to load your server).
Keep everything running
No action needed. Choose this if you're still actively using your manga librarian.
- The MCP server loads automatically every time Claude Desktop starts.
- The scheduled task runs the daily digest each morning at 07:00.
- Your data/library.db continues to store your reading progress locally.
- There are no ongoing costs. Everything runs on your machine.
Pause - I'll come back to this later
Disable the scheduled task to stop the daily digest while keeping everything else intact for later.
- Disable the scheduled task by running this command in PowerShell:
schtasks /change /tn "MangaLibrarianDigest" /disable
- The MCP server still works on-demand. You can ask Claude for discoveries or library updates any time.
- Your library database and all project files remain on disk, ready to resume.
- To re-enable the digest later, run:
schtasks /change /tn "MangaLibrarianDigest" /enable
Delete - I don't want to use this again
Remove all project resources completely. This deletes your library database, reading history, and all project files.
- Delete the scheduled task by running this command in PowerShell:
schtasks /delete /tn "MangaLibrarianDigest" /f
- Remove the MCP server entry from your Claude Desktop configuration. Open %APPDATA%\Claude\claude_desktop_config.json in your code editor.
- Delete the manga-librarian entry from the mcpServers object, then save the file.
- Restart Claude Desktop so it picks up the config change.
- Delete the project folder by running this command in PowerShell:
Remove-Item -Recurse -Force "$env:USERPROFILE\Desktop\manga-librarian"
- Confirm the folder is gone by running:
ls "$env:USERPROFILE\Desktop\manga-librarian"
You should see an error stating the path does not exist. That confirms everything is cleaned up.
That's a Wrap!
That's a Wrap!
Nice work. You just built a private, ad-free AI librarian that runs entirely inside Claude Desktop via MCP.
You've learned how to:
- Build a custom MCP server that turns Claude Desktop into a personal discovery engine, filling every browse category across manga, manhwa, and manhua with verified data from AniList, MangaDex, and Jikan.
- Verify every surfaced link against a legal-platform allowlist with HTTP resolve checks and explicit abstention when no official source exists.
- Build a private, local-first SQLite library carrying titles from bucket list through re-reading with chapter progress, ratings, and notes, plus a daily digest scheduled via Windows Task Scheduler for a two-minute morning habit.
- Secret Mission: Add because-you-read recommendations, hidden gems, a weekly release calendar, and an adversarial verify pass that red-teams every link.
Ready to quiz yourself?