mal-mcp
Exposes a user's MyAnimeList data (watch list, scores, statistics) as MCP tools for AI assistants to analyze taste, build statistics, and make recommendations.
README
mal-mcp
A stateless MCP (Model Context Protocol) server that exposes a user's personal MyAnimeList data — watch list, scores, watch status, episode progress — as MCP tools, so an AI assistant (e.g. Claude) can analyze taste, build statistics, and make recommendations.
Built with Python 3.12, FastMCP 3 (streamable-http transport) and httpx. Designed to run as a container behind an Obot MCP gateway.
Architecture
MCP client (Claude) ──> Obot gateway ──> mal-mcp (this server) ──> MAL API v2
│ │
│ OAuth flow, │ reads Authorization: Bearer <token>
│ token storage/ │ from each request and forwards it
│ refresh │ to api.myanimelist.net — nothing stored
- No OAuth login flow in this server. The gateway performs the interactive OAuth flow
(PKCE, callback, token exchange) and forwards
Authorization: Bearer <MAL access token>with every MCP request. Alternatively — because Obot cannot drive MAL'splain-PKCE flow today (see below) — the server can renew its own access token from a provisionedMAL_REFRESH_TOKENvia the standardrefresh_tokengrant: a single POST, no login flow, tokens held in memory only. - Stateless. Each tool call resolves the token (request header first), uses it for the
MAL API calls of that one invocation, and persists nothing to disk. No sessions are kept
(
stateless_http=True), so replicas can scale freely. - Rate-limit friendly. The MAL rate limit is undocumented (community practice: ~1 req/s;
abuse surfaces as HTTP 403 "DoS detected"). Every list-based tool fetches the user's whole
library in a single paginated pass with an explicit
fieldsparameter — there are no per-anime requests. 403/429 responses are retried with exponential backoff (1s/2s/4s).
Tools
Anime
| Tool | Description |
|---|---|
get_my_anime_list(status_filter?, sort?, limit=100, offset=0) |
A page of the user's list (bounded; has_more + offset for paging): title, watch status, score, episode progress, genres, community mean, studios. |
get_user_stats() |
Locally computed summary: status/score/genre/media-type/decade distributions, total episodes, estimated watch time, user-vs-community score deviation, top studios. |
search_anime(query, limit=10) |
Public catalog search (compact results, truncated synopsis). |
get_anime_detail(anime_id) |
Full public detail incl. related anime, recommendations, statistics, and the user's own list entry if present. |
analyze_taste() |
Token-efficient raw export of the whole list (grouped by status, sorted by score) for the calling model to analyze — this tool itself performs no analysis. |
Manga
| Tool | Description |
|---|---|
search_manga(query, limit=10) |
Public manga catalog search (chapters/volumes, authors, genres). |
get_manga_detail(manga_id) |
Full manga detail incl. authors, serialization magazines, related works, recommendations, and the user's own entry if present. |
get_my_manga_list(status_filter?, sort?, limit=100, offset=0) |
A page of the user's manga list with chapter/volume progress. |
Discovery
| Tool | Description |
|---|---|
get_anime_ranking(ranking_type, limit=25) |
MAL's official rankings: all, airing, upcoming, tv, ova, movie, special, bypopularity, favorite. |
get_manga_ranking(ranking_type, limit=25) |
Manga rankings: all, manga, novels, oneshots, doujin, manhwa, manhua, bypopularity, favorite. |
get_seasonal_anime(year, season, sort?, limit=25) |
Anime of one broadcast season (winter/spring/summer/fall). |
get_suggested_anime(limit=25) |
MAL's personalized suggestions for the authenticated user. |
Users
| Tool | Description |
|---|---|
get_my_profile() |
The authenticated user's profile + lifetime anime statistics. MAL exposes this only for @me. |
get_user_anime_list(user_name, ...) |
Another user's public anime list (403 usually = private list or unknown user). |
get_user_manga_list(user_name, ...) |
Another user's public manga list. |
Write tools — these modify the user's MAL list
| Tool | Description |
|---|---|
update_my_anime_entry(anime_id, ...) |
Update score/status/episode progress/tags of a list entry — or add the anime to the list (MAL creates the entry if absent). Only provided fields change. |
delete_my_anime_entry(anime_id) |
Permanently remove an anime from the list (score/progress/tags are lost; cannot be undone). |
update_my_manga_entry(manga_id, ...) |
Same as the anime variant, with chapter/volume progress. |
delete_my_manga_entry(manga_id) |
Permanently remove a manga from the list. |
Aggregate tools (get_user_stats, analyze_taste) fetch the entire list in one paginated
pass (safety cap: 20,000 entries — beyond that a truncated/WARNING marker is included).
paging.next URLs are validated (https + api.myanimelist.net) before being followed, so
the bearer token can never be sent elsewhere.
All tools are read-only. Facts about the MAL API this server relies on (fields syntax, pagination, limits, error shapes) are documented in NOTES.md.
MAL application registration
- Go to https://myanimelist.net/apiconfig → Create ID.
- App Type:
Web— this is what makes MAL issue a Client Secret (Android/iOS/Other types are public clients without a secret). - App Redirect URL: the callback of whatever performs the OAuth flow. For Obot's
built-in flow that is
https://<your-obot-host>/oauth/mcp/callback; if you obtain tokens manually (see below), a localhost URL such ashttp://localhost:8080/callbackworks. - Note the Client ID (32 chars) and Client Secret (64 chars). Never bake them into this server or its image — they belong to the gateway/flow side only.
MAL OAuth2 reference (for the gateway configuration)
| Item | Value |
|---|---|
| Authorize URL | https://myanimelist.net/v1/oauth2/authorize |
| Token URL | https://myanimelist.net/v1/oauth2/token |
| PKCE | plain only — MAL does not support S256. code_challenge must equal code_verifier (43–128 chars). |
| Scopes | None (a token grants the full API surface for that user). |
| Token usage | Authorization: Bearer <access_token> |
| Lifetimes | Docs say access = 1 hour, refresh = 1 month; in practice MAL returns expires_in=2678400 (31 days) for access tokens. |
⚠️ The
plain-PKCE constraint matters. Any OAuth client that hardcodesS256(most modern ones, including Obot today — see below) cannot complete MAL's flow.
Deploying behind Obot
Register the server (Containerized runtime)
Build and push the image (see Docker below), then in the Obot admin UI: MCP Management → MCP Servers → Add MCP Server, runtime Containerized:
| Field | Value |
|---|---|
| Image | ghcr.io/umutkdev/myanimelist-mcp:latest |
| Port | 8000 |
| Path | /mcp |
In the same form, add environment fields for the token setup you chose (see "Getting the
token to the server" below) — recommended: MAL_REFRESH_TOKEN, MAL_CLIENT_ID,
MAL_CLIENT_SECRET (all sensitive).
Or as a catalog entry:
name: MyAnimeList
serverUserType: multiUser
runtime: containerized
containerizedConfig:
image: ghcr.io/umutkdev/myanimelist-mcp:latest
port: 8000
path: /mcp
env:
- key: MAL_REFRESH_TOKEN
name: MAL Refresh Token
required: true
sensitive: true
description: MyAnimeList OAuth refresh token (obtained once; see README)
- key: MAL_CLIENT_ID
name: MAL Client ID
required: true
sensitive: false
description: MyAnimeList API app Client ID
- key: MAL_CLIENT_SECRET
name: MAL Client Secret
required: false
sensitive: true
description: MyAnimeList API app Client Secret (Web app type only)
⚠️ Env-provisioned tokens mean ONE shared MAL account. With
serverUserType: multiUserthe admin configures the env values once and every user of this registration talks to the token owner's private MAL list (scores, watch history, plan_to_watch/dropped) and shares that account's rate limit. Use env tokens only for a personal / single-operator gateway. For a multi-person gateway, register the server as single-user so each user supplies their ownMAL_REFRESH_TOKEN, or use a Remote registration where each user sends their ownAuthorizationheader (which always takes precedence over env tokens).
Getting the token to the server — current reality (read this)
This server just needs Authorization: Bearer <MAL access token> on each request; it does
not care who put it there. As of Obot v0.23.x there is a real incompatibility to be aware of:
- Obot's OAuth support ("Static OAuth") takes only a Client ID/Secret and discovers
authorize/token endpoints via the MCP auth spec (401 +
WWW-Authenticate→ RFC 9728 → RFC 8414). MAL publishes no such metadata, and Obot's OAuth client hardcodes PKCES256(verified innanobotandmcp-oauth-proxysources), while MAL supports onlyplain. Obot's built-in OAuth flow therefore cannot drive MAL directly today.
Working options, in order of practicality:
- Self-renewing refresh token (recommended — set up once). Run the manual flow below
ONCE and keep the
refresh_tokenfrom its output. Provision three env fields on the containerized server:MAL_REFRESH_TOKEN,MAL_CLIENT_ID, andMAL_CLIENT_SECRET(omit the secret for non-Web public clients). The server then mints and renews access tokens itself before they expire — no monthly re-pasting. Rotated tokens live in memory only; MAL keeps previously issued refresh tokens valid after rotation (verified empirically), so the env value keeps working across container restarts. - Static access token (quick test). Set
MAL_ACCESS_TOKENinstead — simplest possible wiring, but MAL access tokens last ~31 days in practice, after which you must paste a fresh one. For a Remote registration, a user-suppliedAuthorizationheader (Bearer <token>) works too and always takes precedence over env-based tokens. - A bridging OAuth proxy in front of this server that speaks the MCP auth spec toward
Obot and
plainPKCE toward MAL. Out of scope for this repository. - Static OAuth, later. If Obot gains configurable/
plainPKCE (or MAL gainsS256+ metadata discovery), switch to Static OAuth with the MAL Client ID/Secret and callbackhttps://<obot-host>/oauth/mcp/callback— no changes needed in this server.
Obtaining a MAL access token manually (documentation only — not part of the server)
Because MAL uses plain PKCE, the verifier and challenge are the same string:
# 1) Generate a code verifier (43-128 chars)
VERIFIER=$(python3 -c "import secrets; print(secrets.token_urlsafe(64)[:100])")
# 2) Open this in a browser, log in, and approve (redirect_uri is required in practice —
# MAL can answer "400 Bad Request" when it is omitted, even with a single registered URL;
# the value must exactly match the App Redirect URL and be URL-encoded):
# https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=<CLIENT_ID>&code_challenge=$VERIFIER&code_challenge_method=plain&state=x&redirect_uri=<URL_ENCODED_REDIRECT_URL>
# You'll be redirected to your registered redirect URL with ?code=<CODE>
# 3) Exchange the code for tokens:
curl -s https://myanimelist.net/v1/oauth2/token \
-d client_id=<CLIENT_ID> -d client_secret=<CLIENT_SECRET> \
-d grant_type=authorization_code -d code=<CODE> \
-d code_verifier=$VERIFIER -d redirect_uri=<REDIRECT_URL>
# → {"token_type":"Bearer","expires_in":2678400,"access_token":"...","refresh_token":"..."}
Keep the refresh_token — that is what goes into MAL_REFRESH_TOKEN for the
set-up-once option; the access_token is what you'd use for the static/header options.
Running locally
uv sync # install dependencies
uv run pytest # unit tests (pure helpers, no network)
uv run python -m mal_mcp.server # serves http://0.0.0.0:8000/mcp (streamable-http)
Environment variables
| Variable | Default | Purpose |
|---|---|---|
PORT |
8000 |
HTTP listen port |
HOST |
0.0.0.0 |
Bind address |
MAL_REFRESH_TOKEN |
(unset) | Enables self-renewing tokens: the server mints/renews access tokens via the refresh_token grant (requires MAL_CLIENT_ID). In-memory only. |
MAL_CLIENT_ID |
(unset) | MAL app Client ID, needed for the refresh grant. |
MAL_CLIENT_SECRET |
(unset) | MAL app Client Secret — required for "Web"-type apps, omit for public clients. |
MAL_ACCESS_TOKEN |
(unset) | Static fallback access token (expires ~31 days). Used only when no Authorization header arrives and no refresh setup exists. |
Token precedence per request: Authorization header → refresh-token manager → MAL_ACCESS_TOKEN.
The server never writes any of these anywhere.
Test with MCP Inspector
npx @modelcontextprotocol/inspector
In the Inspector UI: transport Streamable HTTP, URL http://localhost:8000/mcp, and add
a custom header Authorization: Bearer <your MAL access token>. tools/list should show
the five tools; get_my_anime_list / get_user_stats return your real data.
Quick smoke test with curl
# List tools (stateless mode: no session handshake needed)
curl -s -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Call a tool with your token
curl -s -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $MAL_TOKEN" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_user_stats","arguments":{}}}'
Calls without an Authorization header return an actionable error; an expired/invalid
token surfaces MAL's 401 as "MAL rejected the access token…".
Docker
Prebuilt multi-arch image:
docker run --rm -p 8000:8000 ghcr.io/umutkdev/myanimelist-mcp:latest
Or build locally:
docker build -t mal-mcp .
docker run --rm -p 8000:8000 mal-mcp
The image is python:3.12-slim + uv, runs as a non-root user, exposes port 8000, and
serves the MCP endpoint at /mcp.
Project layout
src/mal_mcp/
├── server.py # FastMCP app, bearer-token helper, 5 tools, stats/format helpers
└── mal_client.py # MAL API wrapper: fields, pagination (paging.next), retries, error mapping
tests/test_stats.py # unit tests for the pure helpers
NOTES.md # verified MAL API / FastMCP / Obot facts (source for the choices above)
PLAN.md # design plan
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。