开发者分享提交MCP服务器到OpenAI目录的经历,九天后收到笼统拒信,实际排查出三个服务器bug和一个测试用例本身的问题,整个定位过程耗时一整天。
我们在某个周四向 OpenAI 的目录提交了我们的应用。九天后,收到了这个:
您的一个或多个测试用例未产生正确结果。请重新运行所有提交的测试用例,并使工具行为/输出与文档中记录的预期结果保持一致。请确保相同的测试用例在 ChatGPT 网页版和移动端上都能持续通过。
这就是全部的拒绝理由。没有指明是哪个工具,没有附上对话记录,没有日志行。就一段话,外加一个链接——如果你认为他们搞错了,可以申诉。
他们没有搞错。那段话里提到的每一个症状都是真实存在的,而找出原因花了我们整整一天:用生产环境跑我们自己的工具,就像一个陌生人那样。共有四个失败:三个服务端 bug,以及一个出在测试用例本身的问题。三个服务端 bug 中,有两个意味着那些工具自上线以来在生产环境中从未正常工作过——对任何人都没有过。
我们做的是 Xenition,一个 AI 工作空间;这次提交的是我们的 MCP server——49 个工具,让 ChatGPT 能够在用户的工作空间里创建和编辑真实的产物:文档、演示文稿、白板、账本、笔记、电子表格。如果你正准备在这个月提交,这篇文章是我希望在提交前一周就读到的东西——每个 bug 都点了名,还有那一个审查技巧,它本来能 catch 到全部四个。
我会原名原样地列出我们自己的 bug,而不是转述。这些都不是产品特有的问题。
普通商店的审核员会点击过一遍你的 UI。你的 UI 是你看过一万遍的东西。
目录审核员做的则是你几乎肯定从未做过的事:他们拿你写的测试用例,用提示词喂给模型,然后观察你的工具实际返回了什么——然后在第二个客户端上再来一遍。就是这部分把我们击垮了。审查的表面不是你的产品,而是:
一个新的账号。 没有你在调试时手动埋入的状态。
一次单轮的工具调用。 没有后续。无论你的工具返回什么,那就是整个对话。
两个措辞不同的客户端。 ChatGPT 网页版和移动版不会用完全相同的表述来表达同一个用户意图,所以你的工具对于同一个测试用例会收到两个不同的字符串。
有人在读模型的句子,而不是你的 JSON。
我们四个失败每一个都能在这个列表里找到。没有任何一个是从我们自己的应用中触发的。
我们的产物存储有一个 List,它查询内容(content)和元数据(meta)为 NULL 的记录。这不是 bug——这是图书馆网格背后那条查询,用来渲染卡片。卡片需要标题、类型和时间戳;为了画一墙磁贴就把每份文档的完整正文传过去是荒唐的。
然后工作空间工具来了——list_tasks、add_task、query_ledger、add_ledger_entry、search_notes——它们的查询辅助函数走了同一个 List。这些调用方随后每一个都解析了它刚请求到的文档。
以下是一个完全健康的工作空间的用户遇到的状况:
再读一下最后一行,因为它让我心里一紧。为 Acme 输入一张发票,然后为 Globex 再输入一张,剩下的账本就只剩 Globex 这一行了。不是报错。不是警告。是成功消息,而用户的数据被替换成了单独一行。空的解析看起来和空文档完全一样,所以"往已有账本加一行"和"创建一个只有一行的账本"变成了同一个代码路径。
日期才是令人惭愧的部分。让内容变空的那个 List 改动发生在 7 月 20 日。这些工具在 8 月 10 日在其基础上上线。我们在 8 月 13 日提交。它们从未正常工作过,在生产环境中一次都没有——而我们自己三天的使用也没有发现它,因为我们自己的应用中没有任何东西通过 MCP 调用工作空间。
修复方案是四行代码加一个重命名:
// ListFull 是加载了 content 和 meta 的 List。需要读取每个产物内部内容的调用方——
// 白板的卡片、账本的行、笔记的正文——需要的是这个,而不是 List。
func (s *Store) ListFull(ctx context.Context, userID, workspaceID, typeFilter, search, projectID string) ([]Artifact, error) {
在调用处,加上原因说明,这样就不会有人悄悄把它换回去:
// ListFull,不是 List:这里每个调用方都要读产物的文档——白板的卡片、账本的行、
// 笔记的正文。List 为图书馆网格清空了 content。
return s.artifacts.ListFull(ctx, uid, "", typ, query, "")
这个教训可以很好地泛化到 MCP 之外:
为某一个调用方优化的读取路径,是等待第二个调用方触发的数据丢失 bug。
List 返回空白内容对于网格是特性。它在写入方用它来判断某样东西是否已存在的那一刻,变成了静默破坏。如果一个查询为了性能而丢弃了字段,名字必须说出来——List 对比 ListFull——因为类型签名不会说。两者都返回 []Artifact。两者都能编译通过。只有其中一个会如实告诉你里面有什么。
我们的编排器有一个澄清门禁。模糊的需求进来,问题返回——在聊天窗口里这是好的行为,人类回答后生成继续。
MCP 工具调用是一轮的。里面没有人类。所以一份单薄的需求导致 create_slides 大约两秒后返回了:
引擎未返回演示文稿
技术上来说,没毛病。引擎返回了一组问题,而没有人来回答它们,所以工具只能报告它能看到的东西。
现在这个细节把这个普通 bug 变成了拒绝通知的确切措辞:门禁是否触发取决于需求怎么表述,而 ChatGPT 在网页版和移动版上对同一个用户意图的措辞不同。同一个测试用例,同一个账号,同一分钟——在一个客户端通过,在另一个失败。"请确保相同的测试用例在 ChatGPT 网页版和移动端上都能持续通过"不是样板话。它是对我们 bug 的描述,写这段话的人刚刚亲眼看到了它发生。
修复方案是告诉引擎这里没有人:
// NoClarify,因为这里没有人可以回答。需求模糊时编排器的澄清门禁会返回问题而不是
// 产物,而 MCP 调用是一轮的。
req := engine.Request{Message: brief, UserID: uid, Skill: skill, NoClarify: true}
不过一个标志位是一个承诺,而承诺会被下一个触碰编排器的人打破——所以后面还有一个保护,而保护上的注释是值得抄过去的部分:
// 双保险:上面的 NoClarify 应该阻止这种情况,但如果门禁再次触发,调用方会得到一个
// 可以处理的东西,而不是"引擎未返回演示文稿"。
如果门禁真的触发了,工具现在会直接返回问题本身。收到三个问题的模型可以向用户提问;收到"引擎未返回演示文稿"的模型只能道歉。
管道中任何交互式门禁在调用方是机器的时候都会变成挂起或谎言。澄清循环、同意提示、"你确定吗?"、等待重试的限速退避——在审核员发现之前把每一个都找出来。
小 bug,最糟糕的对外表现。
check_agent_run 轮询一个后台智能体。问它一个不存在的 run id,它回答:
对于任何 id。永远都是。一个拼错的 id、一个编造的 id、一个来自不同账号的 id——全是"仍在处理中"。收到这条消息的模型会告诉用户耐心等待,然后再告诉一次,用户就在等待一个从未被创建的任务。
修复方法是把 404 变成错误,而不是变成信息的缺失,而且错误要说出恢复方式:
未找到 id 为"nope"的智能体运行——使用 run_agent 返回的 missionId
一般形式:"未找到"和"未完成"绝不能合并成同一个回复。其中一个是关于世界的已完成的事实;另一个是等待请求。任何在真相是不存在的时候回答 pending 的状态端点都会把人搁在半路。
这个没有服务端修复,而且我敢赌大多数提交都会犯这个错。
我们的八个用例在门户里看起来合理。案例 1 创建了一个演示文稿。案例 2 在工作空间中搜索案例 1 创建的演示文稿——预期结果文本还很贴心地写着:先运行案例 1。
一个以不同顺序、在新的聊天中、或在第二台设备上运行它们的审核员会看到案例 2 失败。而他们这样做是对的。我们写的是一个只有在作为脚本由读过我们脚注的人执行时才能通过的套件,然后把它交给了两个客户端上的陌生人。
重写后,每个用例都是独立的。我们在这个文件顶部写了规则,这样下一组人就不会退化:
自包含。 用演示工作空间播种数据,这样读取用例不需要写入用例先跑就能找到东西。
具体的需求。 模糊的需求会触发澄清门禁。NoClarify 在服务端修复了它,但具体的需求也能让输出足够稳定从而可以描述。
预期结果描述的是形状,而非精确措辞。一份幻灯片的标题来自模型,每次运行都不同。"创建并渲染了一个 5 页的幻灯片"每次都能通过;但断言一个具体的标题则不能。
不要依赖哪个 artifact 是"最新的",除非结果表述允许其中任何一个。
每个案例都针对生产环境、从空白账户、在两个客户端上验证。
一个体现这种差异的例子,来自同一个工具:
第二个是可证伪的,可由陌生人验证。这是唯一重要的属性。
我意外做对的部分:注解
在准备提交时,我们为全部 49 个工具添加了注解——readOnlyHint、destructiveHint、openWorldHint——并需要为每个工具上的每个 hint 写理由。147 段短文。感觉像在填表格。
但并不是,因为规范中有这么一行,我当初绝对会押注反对它:
// DestructiveHint and OpenWorldHint are *bool in the Go SDK, and the spec's default when they
// are ABSENT is true.
Absent 意味着 destructive。Absent 意味着 open-world。在我们填入这些之前,我们交付的每个工具——search_artifacts,一个纯读操作——都向每个客户端宣传自己是一个 destructive、open-world 操作。不是外观问题:客户端用这些 hints 来决定什么需要确认对话框、什么可以无人值守运行。
我们用构造函数而非分散的字面量来解决了这个问题,每种行为类一个,每个都把定义写在注释里:
// hintsRead: 读取用户自己的工作区,不读写其他任何东西。
func hintsRead() *mcp.ToolAnnotations {
no := false
return &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: &no, OpenWorldHint: &no}
}
// hintsAdd: 向工作区添加新内容;不会替换任何已存在的东西。
func hintsAdd() *mcp.ToolAnnotations {
no := false
return &mcp.ToolAnnotations{DestructiveHint: &no, OpenWorldHint: &no}
}
// hintsAct: 将工作交给一个此调用无法界定其效果的系统——后台智能体,或针对已连接第三方应用的待处理操作。因为运行内容是开放的,所以是 destructive。
func hintsAct() *mcp.ToolAnnotations {
yes := true
return &mcp.ToolAnnotations{DestructiveHint: &yes, OpenWorldHint: &yes}
}
用构造函数而非包级变量,这样不会有任何两个工具共享并修改同一个注解值。
写 147 个理由也强制提出了一个问题——这个工具实际上做什么——而且是针对那些很久没人问过的工具。其中三个的注解与它们名称所暗示的恰好相反:
create_app 是只读的。它不持久化任何内容。它构建一个预填充的深度链接到构建器。
check_3d_model 不是只读的。当轮询成功时,它会把完成的网格写入 artifact。
approve_action 是 destructive 和 open-world 的;deny_action 两者都不是。批准将一个待处理操作释放到第三方应用。拒绝则在本地关闭一个请求。
如果审查员质疑我们的注解,就是这三个,理由文本已经解释了每一个。按调用做的事来注解,不要按其名称中的动词来注解。
演示账户是提交的一部分
这里有两件事花了我几个小时,而且会花你同样多的时间。
一条重复行可能是负载必需的。我们的种子工作区有两个同名文档,所以一个搜索案例返回了同一个名称两次。看起来很粗糙,所以我删除了更新的副本——然后 grounded-answer 案例变得不稳定。它之前每次运行都返回相同的六步答案;之后一次运行正确回答了,下一次却说提供的段落没有描述入职流程。删除重复项后,语料库变薄,低于那个答案所需的阈值。
不稳定正是我们刚被拒绝的精确失败模式。副本恢复后,答案在三次运行中再次一致,文档里也这样写了:
重复行比不可靠的答案更易读。
清理你自己的调试留下的东西。针对生产验证全部 49 个工具后,演示账户里满是垃圾——一个"开发看板"、"一个预算表"、四份几乎相同的销售幻灯片。审查员打开那个账户应该看到一个像真实用户的工作区,而不是测试遍历后的残骸。
门户摩擦,你可以围绕它做计划
有四件事浪费了时间,而且不是任何人的 bug:
工具列表是虚拟化的。页面读取工具只会返回其中一部分。一次一个工具地填写每个工具的字段是唯一可靠的方式。
SPA 有加载竞态。直接导航到深度链接的区域有时会渲染出"让组织管理员为你分配 api.apps.read 权限的角色",而 Skills 标签页短暂地显示根本没有上传的技能。两次都是通过插件列表完整重载后显示了真实状态。这不是权限问题——我差点提交了一张关于我已经拥有的角色的支持工单。
Scan Tools 需要先进行 OAuth 授权。它打开你自己的同意页面,必须有人在其中登录后扫描才能运行。
技能安全扫描很慢。门户警告最多需要两小时。不要把提交安排在每天的最后一个小时。
还有一件事在计划发布前值得知道:审批不等于发布。通过后,门户解锁一个发布选项,由人工按下它。上线时刻仍由你决定。
提交前检查清单
以上所有内容,作为下次我真的会执行的清单:
[ ] 针对生产环境运行每个工具,从你手动创建了状态的账户开始。
[ ] 对于每个读工具:它看到的是内容,还是一个被优化掉的副本?对照已知行数检查。
[ ] 对于每个写工具:"我什么都没找到"是否曾经变成"那我就创建一个新的"?这是一条数据丢失路径。
[ ] 是否有任何工具位于交互式门禁——澄清、同意、确认——前面,而没有人类来回答?
[ ] 是否有任何状态工具把未知 id 报告为 pending?
[ ] 测试案例以任意顺序、在新的聊天中、在 web 和移动端上都能通过吗?
[ ] 是否有任何预期结果断言了模型生成的措辞?应该断言形状。
[ ] destructiveHint / openWorldHint 在每个工具上都显式设置了吗?Absent 意味着 true。
[ ] 每个注解的理由是由调用做的事来支撑的,而不是由其名称?
[ ] 演示账户是否已播种,并清理干净了你自己测试留下的东西?
[ ] 你读了模型对每个案例的句子,而不只是你的 JSON?
如果下星期有人要提交,我会告诉他们的
拒绝邮件只有一段话,而且会感觉很委屈。按字面理解而不是个人情绪:"没有产生正确结果"和"在 web 和移动端都持续出现",在我们的情况下,是对三个 bug 和一个破损套件的精确技术描述。我们没有申诉,因为没什么好申诉的——他们作为用户运行了我们的工具,看到了我们的产品对他们撒谎。
令人不舒服的收获与任何目录无关。是这个:一个 MCP 服务器是第二个产品,它有自己的用户、自己的状态、自己的 bug。我们的服务器有两个工具从未在生产环境中工作过,而我们自己的应用在结构上无法察觉,因为我们的应用不通过 MCP 调用自己。那次审查的每一小时都发现了真实的问题。
在撰写本文时,修复已在线上生效,在生产环境重新验证——list_tasks 读到 30 条,add_task 后变成 31 条,账本保留了两张发票,未知的运行 id 是错误——但重新提交还没发出。如果第二次尝试教会我什么新东西,我也会写出来。
如果你构建了一个以客户端方式调用自己 MCP 服务器的测试工具——空白账户、一次对话、两个客户端、读模型的句子而不是你的 JSON——我很想读到它。这是我仍然缺失的那块拼图,而且这是唯一能在陌生人之前发现所有这四个问题的东西。
我在 Xenition 工作——一个用于文档、幻灯片、代码、应用和媒体的 AI 工作区,免费开始,支持 web、桌面和两个应用商店。它的 MCP 服务器有 49 个工具。其中两个,直到最近,从未工作过。