mal-mcp
A stateless MCP (Model Context Protocol) server that exposes a user's personalMyAnimeList data — watch list, scores, watch status, episodeprogress — as MCP tools, so an AI assistant (e.g. Claude) can analyze taste, buildstatistics, and make recommendations.
Built with Python 3.12, FastMCP 3 (streamable-http transport) andhttpx. 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 flowtoday (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 theMAL 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 wholelibrary in a single paginated pass with an explicit
fieldsparameter — there are noper-anime requests. 403/429 responses are retried with exponential backoff (1s/2s/4s).
Tools
| 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. |
Aggregate tools (get_user_stats, analyze_taste) fetch the entire list in one paginatedpass (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, sothe 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/Othertypes are public clients without a secret). - App Redirect URL: the callback of whatever performs the OAuth flow. For Obot'sbuilt-in flow that is
https://<your-obot-host>/oauth/mcp/callback; if you obtain tokensmanually (see below), a localhost URL such ashttp://localhost:8080/callbackworks. - Note the Client ID (32 chars) and Client Secret (64 chars). Never bake them intothis 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 thetoken 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 ofthis 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 apersonal / single-operator gateway. For a multi-person gateway, register the server assingle-user so each user supplies their ownMAL_REFRESH_TOKEN, or use a Remoteregistration where each user sends their ownAuthorizationheader (which always takesprecedence 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 doesnot 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 discoversauthorize/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 belowONCE and keep the
refresh_tokenfrom its output. Provision three env fields on thecontainerized server:MAL_REFRESH_TOKEN,MAL_CLIENT_ID, andMAL_CLIENT_SECRET(omit the secret for non-Web public clients). The server then mints and renews accesstokens itself before they expire — no monthly re-pasting. Rotated tokens live in memoryonly; MAL keeps previously issued refresh tokens valid after rotation (verifiedempirically), so the env value keeps working across container restarts. - Static access token (quick test). Set
MAL_ACCESS_TOKENinstead — simplest possiblewiring, but MAL access tokens last ~31 days in practice, after which you must paste afresh 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 towardObot 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 theset-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 adda custom header Authorization: Bearer <your MAL access token>. tools/list should showthe 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/invalidtoken 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, andserves 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