1. 先说结论:MCP Server 不是玩具,自动发布功能一次就踩穿底线
我最近在做一个 MCP Server 的实战项目,目标是让 AI 助手通过标准协议直接调用内容发布接口,实现“输入一段话,AI 自动写文章、配图、排版、发布”的全流程。听起来很丝滑对吧?结果项目标题就是“自动发布我发错了”——没错,我在测试阶段把一篇还没写完、也没审核的测试文章,直接发布到了正式渠道,而且发布之后 API 没有撤回能力,只能眼睁睁看着错误内容出现在线上。
这个事情让我把 MCP Server 的开发文档、工具定义、参数校验、环境隔离全部重新过了一遍。今天就把这个项目从设计到翻车再到修复的完整过程写出来,尤其是那几个让“发错内容”成为必然的根因,以及我现在强烈建议每个做 MCP Server 自动发布类功能的人都要加的防护机制。无论你是准备入坑 MCP 开发,还是已经在自己项目里接入了工具调用,这篇文章都值得你花十分钟看完,至少能帮你少踩我踩过的这个巨坑。
先说清楚,MCP 的全称是 Model Context Protocol,也就是模型上下文协议。它解决的核心问题是:让 AI 模型不再只能“输出文字”,而是可以安全地调用外部系统——查数据库、操作文件、调 API、发消息。你可以把它理解成一个 USB-C 接口,苹果电脑、安卓手机、充电器只要都支持这个标准,插上就能用。MCP Server 就是“支持这个接口的外设”,而 Claude Desktop、Cursor、各种支持 MCP 的客户端只要能识别这个协议,就能直接调用你自定义的工具。
我做的这个项目,就是写一个 MCP Server,把“内容发布”这个能力封装成标准工具暴露给 AI。AI 收到用户指令后,会自己决定要调用哪个工具、传什么参数,最终把内容发到目标平台。这个流程里,只要有一环的参数没控制住,就是事故。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目背景与整体设计:为什么我要用 MCP 做自动发布
2.1 需求来源:从“手工复制粘贴”到“AI 自动发布”
事情起源于我维护的一个技术博客和公众号。过去我每周要写三四篇文章,每次写完还要手动登录后台、复制粘贴、传图、排版、定时发送。后来我试过用脚本做半自动发布,但脚本的问题在于——它只能按照写死的逻辑执行,你没法跟它“对话”。比如你说“把上周那篇 SQL 优化的文章改个标题发到公众号”,传统脚本根本理解不了“上周那篇”是哪篇。
于是我想到用 MCP Server 来做这件事。我把“内容发布”相关的操作封装成工具,让 AI 自己判断该调用哪个工具、该传什么参数。AI 理解自然语言,MCP Server 提供执行能力,二者一结合,就实现了我想要的“说句话就发布”的效果。
但说实话,这个需求最核心的难点不在于“能发布”,而在于“不能发错”。内容发布本身就是一种高风险操作,发出去的每一条内容都代表你的公开形象,一旦发错,轻则尴尬,重则影响业务。所以我在设计这个项目的时候,给自己的要求是:发布能力要做一个生产级可用的版本,而不是 demo。
2.2 方案选型:为什么是 MCP,不是直接写 API 服务
很多人会问:我直接写一个 HTTP API,让 AI 通过 function calling 来调用不就行了?为什么非要 MCP?
我的回答是:MCP 的价值不在“调用”,而在“标准”。如果我只给 Claude 写一个 function calling 的接口,那这个接口只能在 Claude 里用。换成 GPT、换成 Cursor、换成其他支持 MCP 的客户端,全部要重写。而 MCP 是一个行业标准,我写好一个 Server,任何支持 MCP 的客户端开箱即用,不需要改代码。
另外,MCP 协议本身定义了完整的生命周期管理,包括初始化握手、工具列表获取、工具调用、错误返回、资源订阅等等。我不用自己设计接口规范,只要按照协议实现就行。这点对于“自动发布”这种需要和多平台打交道的场景特别重要——我把微信公众号、知乎、博客后台全部封装成统一的工具接口,AI 不需要知道每个平台的 API 差异,它只需要知道“有一个 publish_content 工具,传入标题、正文、目标平台就行”。
从我实际的开发体验来看,MCP 还有另一个优势:调试友好。MCP Inspector 可以独立于任何客户端单独测试 Server 的工具定义和调用,这比我以前写 function calling 时靠打印调试日志要高效得多。
2.3 工具集设计:发布、草稿、状态查询三件套
我一开始设计的工具集很朴素,只有三个工具:
publish_content:发布内容,支持指定平台、标题、正文、标签save_draft:保存草稿,不对外发布get_publish_status:查询历史发布记录和状态
这个设计看起来“功能完整”,但问题就出在太完整了——publish_content 工具的参数设计过于宽松,没有默认的“安全模式”,导致 AI 在理解用户意图时,一旦出现偏差,就会直接走到发布路径,而没有任何防错机制。
具体来说,publish_content 当时支持以下参数:
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| title | string | 是 | 文章标题 |
| content | string | 是 | 文章正文 |
| platform | string | 是 | 发布平台,如 wechat / zhihu / blog |
| status | string | 否 | 发布状态,可选 publish / draft,默认 publish |
| tags | array | 否 | 文章标签,默认空数组 |
| scheduled_time | string | 否 | 定时发布时间,不传则立即发布 |
你看出问题了吗?status 默认值是 publish。这意味着 AI 在调用工具时,只要它没有显式传入 status: "draft",内容就会被立即发布。这个设计在正常使用时没问题,但在 AI 理解错用户意图的时候,就是致命的。
3. 开发环境与核心代码实现:从工程初始化到工具开发
3.1 环境准备与工程结构
我先说一下开发环境。语言选的是 Python 3.11,MCP 官方 Python SDK 目前已经比较成熟了,支持 FastMCP 这种声明式写法,写起来比直接用底层类要舒服很多。SDK 安装直接 pip install mcp 就行。
工程结构我按功能模块拆的:
text复制mcp-publisher/
├── server.py # MCP Server 主入口
├── tools/
│ ├── __init__.py
│ ├── publisher.py # 发布工具封装
│ └── query.py # 状态查询工具
├── platforms/
│ ├── __init__.py
│ ├── wechat.py # 微信公众号适配器
│ ├── zhihu.py # 知乎适配器
│ └── blog.py # 自有博客适配器
├── config/
│ └── settings.py # 平台配置、环境变量加载
└── tests/
└── test_publisher.py # 本地测试脚本
平台适配器的价值在于:每个平台的 API 千差万别,但我把它们全部收敛成统一的数据结构。wechat.py 里封装的是微信公众号的草稿箱/发布接口,zhihu.py 封装知乎的内容创作接口,blog.py 走的是我自己博客后台的 REST API。对于 MCP Server 来说,它只需要通知“去这个平台发这篇内容”,具体的 HTTP 请求、鉴权、错误处理都隔离在适配器里。
3.2 工具定义:参数设计是事故高发区
工具定义我用的 FastMCP 的装饰器方式,非常简洁。下面是 publish_content 的初始版本:
python复制from mcp.server.fastmcp import FastMCP
mcp = FastMCP("content-publisher")
@mcp.tool()
def publish_content(
title: str,
content: str,
platform: str,
status: str = "publish",
tags: list[str] = None,
scheduled_time: str = None,
) -> dict:
"""发布内容到指定平台。
Args:
title: 文章标题。
content: 文章正文,支持 Markdown 格式。
platform: 发布平台,可选 wechat / zhihu / blog。
status: 发布状态,publish 表示立即发布,draft 表示保存为草稿。
tags: 文章标签列表。
scheduled_time: 定时发布时间,ISO 格式,不传则立即发布。
"""
# 实际调用平台适配器
...
这段代码看起来人畜无害,甚至“功能完整”。但问题恰恰就隐藏在 status: str = "publish" 这个默认值里。作为开发者,我知道默认发布是故意的——大多数时候我确实是想直接发。但作为“AI 工具”,这个默认值等于告诉 AI:你忘记传 status 时,我给你发出去,不用承担责任。
后来我还仔细研究了 MCP 的工具描述规范,发现 AI 对工具参数的理解完全取决于 docstring 和参数注释的清晰程度。如果我在文档里写了“不传 status 默认发布”,AI 就会认为“发布”是常态操作,而不会产生任何警觉。
3.3 平台适配器的实现细节
平台适配器这一层,我的核心要求是“统一接口、各自实现”。每个适配器都暴露两个方法:publish(article) 和 save_draft(article)。article 是一个 dataclass,包含标题、正文、标签、定时时间等字段。
以微信公众号为例,它比较特殊,因为微信的发布流程是两段式的:先上传素材到草稿箱,再通过发布接口把草稿发布出去。所以我封装的时候,save_draft 走的是草稿箱接口,publish 则是在草稿箱接口之后继续调用发布接口。这个逻辑本身不复杂,但如果你不在适配器里处理好异常情况,就会埋坑。
我当时遇到过一个问题:微信公众号的 access_token 有效期只有两个小时,而我测试时用了定时发布,内容在草稿箱里放了两天才去发布,结果 token 早过期了。后来我把 token 刷新逻辑做成了“发布前检查,过期自动刷新”,才解决这个问题。
知乎的接口稍微简单一些,但它的鉴权方式和工作微信完全不同,需要单独适配。这块的经验是:不要试图做一个万能适配器,每个平台的差异太大,老老实实为每个平台写独立模块,反而是最快的路径。
3.4 MCP Server 的启动与联调
Server 写完以后,启动方式非常直接:
python复制if __name__ == "__main__":
mcp.run(transport="stdio")
stdio 传输模式意味着这个 Server 是通过标准输入输出来和客户端通信的。你启动这个 Python 进程,然后 MCP 客户端(比如 Claude Desktop 或 MCP Inspector)会把标准输入输出和进程绑定起来,通过 JSON-RPC 消息进行交互。这个模式的好处是不需要处理网络服务,进程起来就能用;缺点是只能本地调用,没法远程跨机器访问。如果你需要远程访问,MCP 也支持 HTTP/SSE 传输,但本地开发阶段用 stdio 完全够了。
联调阶段我用的是 MCP Inspector,它是官方提供的可视化调试工具。启动以后,它能自动扫描当前 Server 暴露的所有工具,展示每个工具的参数定义,还能直接模拟 AI 发起一次工具调用。我在这里做的最多的测试是:传一组参数,观察工具能不能正确返回结构化的结果。比如传一个 platform="wechat"、status="draft" 的调用,看适配器是否正确把内容保存到草稿箱而不是直接发出去。
这些测试我在本机全部跑通了。但当时我没预料到的是,本地测试跑通和真实场景可控之间,还隔着一条巨大的鸿沟——因为本地测试的时候,是我这个“人”在控制参数,而真实场景里,控制参数的是“AI”。
4. “发错内容”事故全复盘:问题到底出在哪
4.1 事故现场:我让 AI 保存草稿,它给我直接发布
事故发生的场景非常经典,我现在复盘的时候都觉得不可思议。当时我在测试自动发布流程,给 AI 的指令是:“把这篇关于 SQL 索引优化的文章保存到草稿箱,我明天再看看。”
我当时的预期是:AI 应该调用 publish_content,并传入 status="draft",这样内容只会保存到草稿箱,不会对外发布。结果 AI 实际调用工具时,传的是:
json复制{
"title": "SQL 索引优化实践经验",
"content": "...文章内容...",
"platform": "wechat",
"status": "publish"
}
是的,它把 status 传成了 publish,然后我的 Server 收到参数后,没有任何拦截,直接调用微信适配器发布了内容。等我反应过来打开公众号后台,那篇连标题都还没想好、正文里有好几段临时笔记的文章,已经安静地群发给了所有订阅用户。
我当时的处理流程是:先赶紧删掉文章(幸好微信支持删除群发),然后开始排查 AI 为什么要这么传参。这一查,就暴露出了我整个设计里积压了好几个层面的问题。
4.2 根因分析:五个层面的失误叠加
第一层失误:工具描述没有“安全约束”。我在 publish_content 的 docstring 里写了 status: publish 表示立即发布,draft 表示保存为草稿,但没有强调“除非用户明确要求发布,否则优先使用 draft”。AI 在解读用户意图时,会基于工具描述来做判断,如果描述本身没有诱导它走安全路径,它就会按照最自然的方式理解——用户说“保存草稿”,但工具描述里没有说“保存草稿必须用 draft”,它可能上下文里看到我当时在测试自动发布,就把 publish 当成了默认操作。
第二层失误:默认值设计太激进。status 默认是 publish,这等于告诉 AI:不传这个参数就发布。实际上,自动发布场景的安全默认值应该是 draft,因为“内容在草稿箱里不会被外部看到”,这是二进制级别的安全边界。把默认值定为 publish,等于默认所有调用都是安全的、可发布的,这是完全错误的假设。
第三层失误:缺少最终执行前的二次确认。MCP 的工具调用是一次性的,AI 发起调用后,Server 执行、返回结果,中间没有“人机确认”的岔路口。我最初觉得确认步骤会打断体验,所以没做,结果就是这个省略直接导致了事故。现在我的原则是:凡是“不可撤回”的操作,必须有确认机制;凡是“可见性影响范围大”的操作,必须有确认机制。
第四层失误:测试环境与生产环境没有隔离。我当时用的 access_token 就是线上公众号的正式 token,所以测试内容直接发到了线上。如果我有独立的测试公众号、测试知乎号,这些问题最多只会污染测试环境,而不会造成真正的对外事故。环境隔离是测试工程的基础,我却因为偷懒跳过了一整层。
第五层失误:缺少发布前的自动预检。发布内容前,至少应该检查一下内容里有没有明显的临时标记、标题是否为空、字数是否达标。这些校验如果放在平台适配器之前的 Server 层,就能在 AI 传参错误时兜底。
4.3 事故复盘清单
我把这次事故的观察整理成了下面这个表格,方便你对照自己项目里的情况:
| 问题层 | 具体表现 | 风险等级 |
|---|---|---|
| 工具默认值 | status 默认 publish | 高 |
| 缺少显式约束 | docstring 未强调安全路径 | 高 |
| 缺少人工确认 | 调用即执行,无确认步骤 | 高 |
| 环境未隔离 | 测试直接发到生产账号 | 高 |
| 缺少自动预检 | 无标题校验、无临时内容检测 | 中 |
| 无撤回机制 | API 不支持撤回已发布内容 | 中 |
5. 修复方案与加固措施:把“发错”变成不可能
5.1 安全性重构:默认草稿 + 发布白名单
事故后我做的第一件事,就是重构工具参数。status 的默认值从 publish 改为 draft,同时增加 allow_publish 的显式开关。只有 status="publish" 且 allow_publish=true 两个条件同时满足,才允许真正发布;其他任何情况,一律走草稿流程。
新的工具定义如下:
python复制@mcp.tool()
def publish_content(
title: str,
content: str,
platform: str,
status: str = "draft",
allow_publish: bool = False,
tags: list[str] = None,
scheduled_time: str = None,
) -> dict:
"""发布内容到指定平台。
Args:
title: 文章标题。
content: 文章正文,支持 Markdown 格式。
platform: 发布平台,可选 wechat / zhihu / blog。
status: 内容状态,可选 draft / publish。默认 draft,仅保存草稿,不对外发布。
allow_publish: 是否允许直接发布。必须显式设置为 true 且 status 为 publish 时,才会真实发布。
tags: 文章标签列表。
scheduled_time: 定时发布时间,ISO 格式,不传则立即发布。
"""
这个改动从设计上堵死了 AI“忘记传 status 就发布”的问题。你可以理解为:默认值从“危险动作”变成了“安全动作”,而危险动作必须由 AI 显式发起。 这就像火箭发射的按钮一样,默认是锁死的,必须同时用两把钥匙才能启动。
5.2 双重校验:工具层的“人机确认”机制
光有参数默认值还不够。为了确保 AI 在调用工具时不会因为突发奇想就传 publish + allow_publish,我加了一个发布确认机制——在 Server 端实现一个“预发布检查”的内部函数,当检测到请求是真实发布时,会先检查内容、记录审计日志,并通过一个特殊的 return type 把“确认请求”返回给客户端。
这一步我选择用“发布确认对象”的方式实现,核心思路是:真实发布前,Server 会返回一个 PublishConfirmation 结构,包含内容摘要和发布目标,需要客户端再次确认后才执行。后来测试发现,在 MCP 协议工具调用模型中,这个确认过程可以通过调用另一个工具 confirm_publish 完成——AI 拿到发布确认对象后,要么调用确认工具继续,要么取消。
听起来绕,但实际执行起来效果很好。下面是关键实现:
python复制@mcp.tool()
def confirm_publish(confirmation_id: str, approve: bool) -> dict:
"""确认或取消发布。
Args:
confirmation_id: 发布确认 ID,由 publish_content 返回。
approve: 是否批准发布,true 表示批准,false 表示取消。
"""
if not approve:
return {"status": "cancelled", "message": "发布已取消"}
# 执行真实发布
result = release_service.execute(confirmation_id)
return {"status": "published", "data": result}
这种“两步确认”的机制保证了:即使 AI 在第一步误传了 status=publish、allow_publish=true,内容也不会立刻发出,必须再走一步 confirm_publish 才能生效。相当于把原本的“一键发布”变成了“两键发布”,虽然多了一步,但对于发布这种高风险操作来说,多一步意味着多一层保障。
5.3 环境隔离与内容预检:把测试和生产彻底分开
我在配置层加了环境判断。配置文件里区分 TEST 和 PROD 两套环境变量,测试环境指向我自己的测试公众号和知乎小号,生产环境才指向真实账号。启动 Server 时通过环境变量指定当前环境,并且 Server 在收到 platform 参数时,会检查该平台对应账户是否为生产账户,如果是生产账户且当前不是生产环境,直接拒绝执行。
同时我加了一个内容预检流程,在工具真正执行前检查:
- 标题不能为空,且长度不少于 5 个字符
- 正文长度不少于 100 个字符
- 正文中不能包含
TODO、待补、测试一下、临时、删除这段之类的临时标记词 - 如果正文包含临时标记词,直接返回警告并转为草稿
这套预检规则在事故前完全没有,事故后我第一版就写进去了。很多人会觉得内容预检是“产品需求”,不是“技术需求”,但从工程稳定性角度看,这就是安全护栏,和参数校验同等重要。
5.4 回归测试与验证过程
重构完成后,我做了一整套回归测试,覆盖下面这些场景:
| 测试场景 | 预期行为 | 实际结果 |
|---|---|---|
| 只传 title/content/platform | 保存草稿,不发布 | 通过 |
| 传 status=publish 但不传 allow_publish | 保存草稿,不发布 | 通过 |
| 传 status=publish + allow_publish=true | 返回发布确认对象 | 通过 |
| 传 confirm_publish(approve=false) | 取消发布 | 通过 |
| 传 confirm_publish(approve=true) | 发布成功 | 通过 |
| 正文含 TODO 标记 | 转为草稿并提示 | 通过 |
| 测试环境请求发布到生产账号 | 拒绝执行 | 通过 |
这套测试跑下来,我对自己的 MCP Server 才有了基本的信心。一个发布功能,只有当你主动测试过“它不会在什么条件下发出去”之后,你才敢说它是可用的。
6. 自动发布系统的最终工作流与后续思考
6.1 最终的工具调用流程
经过修复,现在我的 MCP Server 在收到“发布文章”指令后,会按照如下流程执行:
- AI 根据用户指令调用
publish_content,传入标题、正文、目标平台 - Server 检查参数:
status默认 draft、allow_publish默认 false - Server 执行内容预检:标题长度、正文长度、敏感词、临时标记
- 如果预检不过,直接保存草稿并返回警告信息
- 如果预检通过且请求是发布,返回
PublishConfirmation确认对象 - AI 根据确认对象向用户展示发布摘要,并询问是否确认发布
- 用户确认后,AI 调用
confirm_publish,传入approve=true confirm_publish执行实际发布,返回发布结果和链接
这套流程既保留了“AI 自动处理”的效率,又嵌入了“关键步骤人工确认”的安全性。用起来之后我最大的感受是:自动发布功能终于不再是“走钢丝”,反而因为有了明确的安全边界,我更敢用它去处理真实内容了。
6.2 安全经验可以迁移到哪些场景
这套设计思路不仅适用于内容发布。任何一个 MCP Server,只要它执行的工具操作是不可逆的、影响范围大的、可能产生公共影响的,都可以套用这套方法:
- 文件删除/重命名类的工具,默认应该只允许操作测试目录,生产目录要二次确认
- 转账/支付类的工具,默认必须金额校验和身份确认
- 用户数据导入/导出类的工具,默认必须生成预览结果,由用户点击确认后再执行
- 任何对外通知类的工具,比如短信、邮件群发,都应该有“内容预览 + 数量确认 + 执行确认”三步机制
MCP 的价值在于让 AI 能操作真实世界,而真实世界的操作天然伴随着风险。越是有风险的操作,越要设计成“默认安全、确认前进”。
6.3 后续要补的功课
虽然现在系统已经被加固过了,但我清楚它还不是完美的。后面我还想继续补三件事:
一是给 MCP Server 加上完整的审计日志。现在虽然记录了每次调用的参数和结果,但还没有把“AI 发起调用时的上下文”完整记录下来。如果下一次再出现问题,我希望能看到 AI 当时看到的完整对话片段,这样才能更快定位意图识别的问题。
二是把发布规则的配置化。目前白名单、预检规则都是写死在代码里的,后续我想把它做成配置文件,让运营同学也能自己调整关键词过滤规则和发布策略。MCP Server 的扩展机制支持读取外部配置,这部分值得好好设计。
三是尝试用 MCP 的能力做更复杂的发布编排。比如 AI 先自动生成文章配图、再调用发布工具、再自动同步到多个平台的组合流程。这些能力已经在 MCP 的 Roadmap 上了,等协议完善一点,我会继续迭代。
这次事故虽然狼狈,但给我的收获远远大于教训。做工具开发,尤其是给 AI 做工具开发,你要时刻记住一件事:你写的代码不只是给人类用的,更是给 AI 用的。AI 会严格按照你的描述去理解和执行,如果你没有把安全约束写清楚,它就可能以你完全想不到的方式把事情做“错”。现在我的体会是,每个面向 AI 的工具函数,都要像面向公众开放的接口一样去设计,默认安全,确认前进。
