内容
# mal-mcp
一个**无状态**的 [MCP](https://modelcontextprotocol.io) 服务器,暴露您的
[MyAnimeList](https://myanimelist.net) 数据 — 观看列表,分数,进度,排名,
推荐 — 作为工具,以及一个高级 **[MCP 应用](https://github.com/modelcontextprotocol/ext-apps)
用户界面**,在聊天中直接呈现这些结果为交互式、动漫风格的界面。任何支持 MCP
的助手都可以分析您的口味,浏览季节,并编辑您的列表;支持 MCP 应用的主机会
通过下面的用户界面执行这些操作。
Python 3.12 · [FastMCP 3](https://gofastmcp.com) (流式 HTTP) · React + Vite 用于用户界面。
## 界面
在 MCP 应用主机(claude.ai / Claude Desktop, ChatGPT, VS Code, Goose, MCPJam…)上,
每个读取工具都会呈现为实时视图。模型仍然会收到一个紧凑的文本摘要;完整的数据
会作为结构化内容传递到 iframe 中。



<table>
<tr>
<td width="50%"><img src="docs/screenshots/list.png" alt="带有状态选项卡、筛选/排序和内联编辑的列表浏览器"></td>
<td width="50%"><img src="docs/screenshots/ranking.png" alt="MyAnimeList 排名,带有排名变化指示器"></td>
</tr>
</table>

详情和列表视图会在原地编辑您的 MyAnimeList 条目(状态 / 分数 / 进度)并
在标题之间导航 — 所有这些都通过相同的工具,因此不会在模型背后发生任何 UI-only
事件。主题跟随主机的浅色/深色模式;封面艺术从 MyAnimeList 的 CDN 加载。
## 工具
**动漫**
| 工具 | 描述 |
|------|-------------|
| `get_my_anime_list(status_filter?, sort?, limit=100, offset=0)` | 您的列表的一页:标题、封面、观看状态、分数、剧集进度、类型、社区平均值、工作室。 |
| `get_user_stats()` | 本地计算的摘要:状态/分数/类型/媒体类型/十年的分布、总剧集数、估计观看时间、用户与社区分数偏差、热门工作室。 |
| `search_anime(query, limit=10)` | 公共目录搜索(带有封面、截断简介的紧凑结果)。 |
| `get_anime_detail(anime_id)` | 完整的公共详情,包括相关动漫、推荐、统计数据和您自己的列表条目(如果存在)。 |
| `analyze_taste()` | 调用模型的原始导出整个列表(按状态分组,按分数排序)以进行分析 — 此工具本身**不执行**任何分析。 |
**漫画**
| 工具 | 描述 |
|------|-------------|
| `search_manga(query, limit=10)` | 公共漫画目录搜索(章节/卷、作者、类型)。 |
| `get_manga_detail(manga_id)` | 完整的漫画详情,包括作者、连载杂志、相关作品、推荐和您自己的条目(如果存在)。 |
| `get_my_manga_list(status_filter?, sort?, limit=100, offset=0)` | 您的漫画列表的一页,带有章节/卷进度。 |
**发现**
| 工具 | 描述 |
|------|-------------|
| `get_anime_ranking(ranking_type, limit=25)` | MyAnimeList 官方排名:所有、正在放送、即将放送、电视、OVA、电影、特别、按人气、收藏夹。 |
| `get_manga_ranking(ranking_type, limit=25)` | 漫画排名:所有、漫画、小说、单篇、二次创作、同人志、漫画、按人气、收藏夹。 |
| `get_seasonal_anime(year, season, sort?, limit=25)` | 一个广播季的动漫(冬季/春季/夏季/秋季)。 |
| `get_suggested_anime(limit=25)` | MyAnimeList 为已认证用户的个性化推荐。 |
| `get_weekly_schedule(timezone?)` | 您的个人每周放送日历:您 `watching` 列表中当前放送的动漫,按放送日分组。使用 `timezone` 参数,否则使用 `MAL_TIMEZONE` 环境变量,否则使用 MyAnimeList 的本地 JST。 |
**用户**
| 工具 | 描述 |
|------|-------------|
| `get_my_profile()` | 您的个人资料 + 生命周期动漫统计数据。MyAnimeList 仅为 `@me` 公开此信息。 |
| `get_user_anime_list(user_name, ...)` | 另一个用户的**公共**动漫列表(403 通常表示私人列表或未知用户)。 |
| `get_user_manga_list(user_name, ...)` | 另一个用户的**公共**漫画列表。 |
**写入工具 — 这些工具会修改您的 MyAnimeList 列表**
| 工具 | 描述 |
|------|-------------|
| `update_my_anime_entry(anime_id, ...)` | 更新分数/状态/剧集进度/标签 — 或将动漫添加到列表中。只有提供的字段会更改。 |
| `delete_my_anime_entry(anime_id)` | **永久**从列表中删除动漫(无法撤销)。 |
| `update_my_manga_entry(manga_id, ...)` | 与动漫变体相同,带有章节/卷进度。 |
| `delete_my_manga_entry(manga_id)` | **永久**从列表中删除漫画。 |
聚合工具 (`get_user_stats`, `analyze_taste`) 会一次性获取整个列表(安全上限:
20,000 条目 — 超出此限制会包含 `truncated`/WARNING 标记)。`paging.next`
URL 在被跟随时会进行验证 (`https` + `api.myanimelist.net`),因此 bearer
token 永远不会被发送到其他地方。已验证的 MyAnimeList API 事实(字段语法、
分页、限制、错误形状)记录在 [NOTES.md](NOTES.md) 中。
## 快速开始
```bash
uv sync # 安装 Python 依赖项
uv run pytest # 单元测试(纯辅助函数,无网络)
uv run python -m mal_mcp.server # 运行 http://0.0.0.0:8000/mcp(流式 HTTP)
```
将 MCP 客户端指向 `http://localhost:8000/mcp`(流式 HTTP),并带有
`Authorization: Bearer <MAL 访问令牌>` 头 — 请参阅 [身份验证](#身份验证)。
### 构建用户界面
服务器在没有 UI 包的情况下运行(它会提供一个小的占位符)。要将实际的
界面构建到轮子/镜像中:
```bash
cd ui
npm ci
npm run build # 生成 src/mal_mcp/ui/dist/index.html(一个自包含的文件)
```
对于 UI 开发,无需主机,`npm run dev` 在 `ui/` 中使用固定数据和
视图切换器渲染每个视图 — 上面的截图就是这些视图。
## 身份验证
服务器需要每个请求的 `Authorization: Bearer <MAL 访问令牌>`;它不会存储任何内容。
请通过以下三种方式之一提供令牌(这是优先级顺序):
1. **`Authorization` 头** — MCP 网关或客户端每个请求发送用户的令牌。
2. **`MAL_REFRESH_TOKEN` (+ `MAL_CLIENT_ID`, `MAL_CLIENT_SECRET`)** — 推荐用于单账户部署。
服务器使用 OAuth `refresh_token` 授权码自颁发和续期访问令牌,仅在内存中。
设置一次,无需每月重新粘贴。
3. **`MAL_ACCESS_TOKEN`** — 静态令牌(过期时间约 31 天);最适合快速测试。
### 获取 MAL 令牌
1. 在 <https://myanimelist.net/apiconfig> 上创建 API 应用 → **创建 ID**,**应用类型:`Web`**
(这使 MyAnimeList 发出客户端密钥)。将重定向 URL 设置为您的 OAuth 回调,
或本地 URL,如 `http://localhost:8080/callback` 用于手动流程。
2. MyAnimeList 使用 **`plain` PKCE 仅**(无 `S256`),因此代码校验器和挑战是相同的字符串。
获取令牌一次:
```bash
# 1) 代码校验器(43-128 个字符);挑战 == plain PKCE 的校验器
VERIFIER=$(python3 -c "import secrets; print(secrets.token_urlsafe(64)[:100])")
# 2) 在浏览器中打开,登录,批准 — 您在重定向 URL 处获得 ?code=<CODE>:
# 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>
# 3) 将代码兑换为令牌:
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":"..."}
```
保留 **`refresh_token`** 用于 `MAL_REFRESH_TOKEN`(选项 2),或 `access_token`
用于标头/静态选项。
### 环境变量
| 变量 | 默认值 | 用途 |
|----------|---------|---------|
| `PORT` | `8000` | HTTP 监听端口 |
| `HOST` | `0.0.0.0` | 绑定地址 |
| `MAL_REFRESH_TOKEN` | *(未设置)* | 通过 `refresh_token` 授权码启用自续期令牌(需要 `MAL_CLIENT_ID`)。仅限内存。 |
| `MAL_CLIENT_ID` | *(未设置)* | MyAnimeList 应用客户端 ID,用于续期授权码。 |
| `MAL_CLIENT_SECRET` | *(未设置)* | MyAnimeList 应用客户端密钥 — 需要“Web”类型的应用。 |
| `MAL_ACCESS_TOKEN` | *(未设置)* | 静态备用令牌(过期时间约 31 天)。 |
| `MAL_TIMEZONE` | *(未设置 → JST)* | `get_weekly_schedule` 的默认 IANA 时区(例如 `Europe/Istanbul`)。该工具的 `timezone` 参数会覆盖它。 |
## Docker
预构建的多架构镜像(镜像中已构建 UI):
```bash
docker run --rm -p 8000:8000 ghcr.io/umutkdev/myanimelist-mcp:latest
```
或本地构建:
```bash
docker build -t mal-mcp .
docker run --rm -p 8000:8000 mal-mcp
```
`python:3.12-slim` + uv,在非根用户下运行,暴露端口 8000,提供 `/mcp`。
由于服务器是无状态的 (`stateless_http=True`),因此它可以在任何 MCP 网关后面自由扩展;
网关(或客户端)提供每个请求的 `Authorization` 头,或您通过上面的环境变量配置令牌。
## 项目布局
```
src/mal_mcp/
├── server.py # FastMCP 应用、令牌助手、20 个工具、统计/格式/摘要助手
├── mal_client.py # MyAnimeList API 包装器:字段、分页(paging.next)、重试、错误映射
├── token_manager.py# 自续期 OAuth refresh_token 授权码(内存中)
└── ui/ # MCP 应用层:ui:// 资源 + meta/ToolResult 助手
└── dist/ # 构建的单个文件 HTML 包(gitignored;从 ui/ 构建)
ui/ # Vite + React + TypeScript 应用(运动动画,6 个视图)
tests/ # 离线单元测试(纯辅助函数、令牌管理器、UI 合同)
NOTES.md # 验证的 MyAnimeList API / FastMCP 事实
```