给掘金 MCP 提了 4 个 issue:一次依赖崩溃、端点失效与静默空值的排查
结论先行
我想让 Claude Code 直接发文章到掘金,用了 iceycn/juejin-release-mcp。结果这个 2026-04 的 alpha 包,12 个工具里 8 个不可用,而且坏在不同层次上:
| # | 问题 | 症状 |
|---|---|---|
| 1 | 依赖约束缺上界 | pip install 装到 MCP SDK 2.x,import 阶段就崩 |
| 2 | 越级相对导入 + 3 个端点失效 | 4 个工具调不通,3 个接口报「请求路由不存在」 |
| 3 | 响应解析按 data.data.* 猜 |
列表类工具拿到数据却读出空值 |
| 4 | 参数名不匹配 | 删除草稿报「参数错误」,标签分页静默失效 |
排查完我把修复 push 到了自己的 fork,并向上游提了 4 个分类 issue。过程中我自己还犯了两次判断错误,一起写在下文------这两次误判比 bug 本身更值得记录。
一、装完就崩:依赖没有上界
sql
$ pip install juejin-release-mcp
$ juejin-release-mcp
Traceback (most recent call last):
File ".../user_juejin/server.py", line 67, in <module>
@server.list_tools()
^^^^^^^^^^^^^^^^^
AttributeError: 'Server' object has no attribute 'list_tools'
pyproject.toml 里写的是:
toml
dependencies = ["mcp>=1.0.0", "certifi>=2024.0.0"]
只有下界、没有上界。而 MCP Python SDK 在 2.x 移除了 Server.list_tools() / Server.call_tool() 装饰器 ,改成了通用的 add_request_handler()。于是 pip install 顺理成章地选了 2.2.0,server.py 第 67 行的模块级装饰器直接抛异常。
这里有个容易被忽略的放大效应 :异常发生在 import 阶段 。进程连 MCP 的 initialize 响应都发不出来就死了,客户端只能看到「Failed to connect」------你以为是配置问题,其实代码根本没跑起来。写 MCP 服务器时,模块级副作用越少越好。
修法是一行:"mcp>=1.0.0,<2"。
二、4 个工具越级相对导入 + 3 个端点失效
把依赖 pin 住之后服务器能起来了,tools/list 也能返回 12 个工具------但其中 4 个一调就报:
go
ImportError: attempted relative import beyond top-level package
原因是 server.py 在函数体内部做延迟导入时用了两级相对导入:
python
# server.py:275 / 324 / 353 / 407
from ..models.request import PublishArticleRequest, UpdateDraftRequest
而包根就是 user_juejin,server.py 位于 user_juejin/server.py,..models 已经越过顶层包。改成 from .models.request 即可。
文件顶部那些 from ..clients... 是正常的 (包内模块之间互导),只有这 4 处在 server.py 里多了一级------所以很不容易注意到。
同时有 3 个接口地址已经失效:
| 常量 | 当前值 | 正确取值 |
|---|---|---|
| 草稿列表 | article_draft/list |
article_draft/list_by_user |
| 标签搜索 | recommend_api/v1/tag/recommend/search |
tag_api/v1/query_tag_list |
| 用户信息 | user_api/v1/user/info |
user_api/v1/user/get,且必须 GET |
最后一条还有个坑:改到 /user/get 之后必须用 GET ,同路径的 POST 照样返回「请求路由不存在」。而客户端把 method 硬编码成了 POST,所以还得给它加个 method 参数。
三、静默空值:最难查的一类
这一类是全文我觉得最值得说的。
所有 from_dict 都假定响应体是双层嵌套:
python
for item in data.get("data", {}).get("data", []):
total=data.get("data", {}).get("count", 0),
但真实接口的 data 大多是数组 、count 挂在顶层 。数组上没有 .get,所以这几个工具直接抛 AttributeError。
修好这一层之后,list_categories 能返回数据了,但我发现分类名是空的------因为真实结构是:
css
data[].category.category_name ← 分类名在这里
data[].tag.tag_name ← 标签名在这里
而代码读的是元素顶层的 item.get("category_name")。
更隐蔽的是文章列表。 发布一篇文章后我回查,list_articles 返回的标题、分类、时间全是空:
ini
修复前:title='' category_id='' ctime=''
data 确实是数组、count 也确实在顶层------所以「修好数组」之后不再抛异常了,但字段全读不出来 。真实结构是 data[].article_info.*,标题、分类、统计都在内层,元素顶层几乎只有 article_id。标签在 data[].tags[] 里,而 article_info.tag_ids 可能是 null。
这类「不报错但值为空」的 bug,比崩溃危险得多:调用方会以为账号真的没有数据,而不会去怀疑解析层。
四、参数名不匹配
python
def delete_draft(self, draft_id: str) -> dict:
data = {"id": draft_id} # ← 接口要的是 draft_id
实测发 {"id": ...} 返回 err_no 2 / 参数错误。麻烦的是这套接口的命名不一致:
| 接口 | 参数名 |
|---|---|
article_draft/update |
id |
article_draft/delete |
draft_id |
article_draft/detail |
draft_id |
另一个是标签列表:代码发 page_no / page_size,但该接口是游标式 分页,认 cursor / limit / sort_type。传错不报错 ,服务端直接忽略------于是永远只返回第一页,翻页静默失效。cursor 还是偏移量而不是页码。
五、我自己犯的两次误判
这部分我犹豫过要不要写,但觉得比 bug 清单更有用。
误判一:我一度以为撞上了供应链攻击
看到 pip 装出来一堆没见过的包名(mcp-types、httpx2、httpcore2),我第一反应是「这不是版本问题,是供应链投毒」,还很确定地这么说出口了。
我错了。 去 PyPI 的 JSON API 查了每个包的元数据:mcp / mcp-types 归属 github.com/modelcontextprotocol/python-sdk,httpx2 / httpcore2 归属 github.com/pydantic/httpx2------全是正规发布,mcp 从 2024-11 起有 70 多个 release。
我犯的错是:拿训练时的版本认知当成了事实。MCP SDK 合法地演进到了 2.x,而我不知道。现在回头看,「一个包名带数字后缀 = 投毒」这种启发式本身就很脆弱。
教训:怀疑供应链问题时,去 PyPI/registry 查归属和发布时间线,而不是靠印象。以及------下结论前先把「我记得」和「我验证过」分开。
误判二:我信了仓库文档,结果文档是错的
仓库自带的 docs/api_testing_conclusions.md 把发布接口的两个字数参数标为「必需 」,错误码表里也写着「参数错误 ← 发布时缺少字数参数」。
我就照这个写进了 issue。结果真去发布时------只传 origin_word_count、完全不传 encrypted_word_count,直接成功了:
json
{"err_no": 0, "err_msg": "success",
"data": {"article_id": "7684439645419307035", ...}}
发布后的文章审核通过、公开页未登录可访问。我推翻了自己写的 issue 结论,回去补了更正评论。
值得一提的是,这个「必需」标注是有实际代价的:按文档字面实现,使用者会被逼着去浏览器开发者工具里手工复制一个其实用不上的值,否则不敢发。
有一件事这次做对了:这条没有实测依据 的结论,我当时明确标了「我没实测、是按文档推断」,所以后来更正起来很干脆。把「已验证」和「按文档推断」分开标注,这个习惯救了这次。
六、我是怎么验的
三类手段,按副作用从小到大:
1. 用真实 Cookie 跑通全部 12 个工具。 读接口随便调,写接口不碰。
2. 拦截 _request 抓实际发出的 URL 和 body。 这是我最推荐的技巧------把客户端的 _request 换成一个只记录不发送的假函数,就能零网络副作用地验证参数名和 URL:
python
captured = []
def fake_request(self, url, data, method="POST"):
captured.append({"url": url, "data": data, "method": method})
return {"err_no": 0, "data": {}}
JuejinClient._request = fake_request
一眼就能看出 {"cursor": "20", "limit": 20, "key_word": "MCP", "sort_type": 1} 对不对,不用真的打接口。
3. 最后才真发布一篇。 因为发布不可逆,它被放在最后。
另外提醒一句:探测路由不要用 create 类接口 。我用空 body 探 article_draft/create 路由是否存在,结果它返回成功------真的建出了一个空草稿。后来老老实实把它删掉了。
七、结果
- 修复版 fork:
github.com/1Sunjiaqi/juejin-release-mcp(remote 设为origin=fork、upstream=原作者,方便后续同步) - 向上游提了 4 个分类 issue,按问题层次拆分,每条都附实测证据与复现步骤
- 本文发出时,上游 issue 区原本是 0 个 issue
有一件事值得单独说:仓库自带的 docs/api_testing_conclusions.md 其实是对的 。它第 30 行写着「草稿列表用 list_by_user 不是 list」、第 37 行写着「删除草稿参数用 draft_id 不是 id」,甚至第 195 行还有一节「代码修改建议」------文档写对了,代码没同步。
所以这类问题的正确修法,往往不是重新逆向 API,而是先把仓库自己的文档翻一遍。逆向的成本比读文档高一个数量级。
可复用清单
排查第三方 API 封装时,我现在的顺序是:
- 先读仓库文档 ,尤其是
docs/下带日期的测试结论------它可能已经写明了正确答案 - 依赖有没有上界 ?lib 类项目声明
>=x不加上界,迟早被上游大版本打崩 - 异常在哪一层?import 阶段崩溃会被客户端伪装成「连接失败」
- 响应结构是猜的还是 dump 过的 ?
data是数组还是字典、count在哪一层,dump 一次就知道 - 有没有静默失败?传错参数不报错、字段读成空值,这两类比崩溃更难发现
- 区分「实测」与「推断」,并在结论里标明
- 写操作留到最后,探测路由别用 create
(完)