为 Boker 构建并上线一个安全的 MCP 文章发布服务
记录 Boker MCP 文章发布服务从权限模型、幂等草稿与版本确认,到生产部署、真实客户端验证和故障恢复的完整过程。
Boker 已经有文章整理 Skill:它能从开发记录生成 Markdown,检查标题、事实和敏感信息,并准备发布元数据。要让远程 Agent 把这些内容送入站点,还缺少一个可认证、可回读、能明确区分保存与发布的接口。
这次实现把 MCP 接入现有 Next.js 服务,让 Codex 通过受控工具完成文章操作。生产数据库迁移已经完成,真实 Codex 已发现全部 9 个工具,并成功读取分类和标签。本文记录这些能力背后的权限与并发设计,也保留部署失败、恢复和客户端验收的事实边界;工具连接成功并不代表生产写入流程已经全部验收。
本地 Skill 需要一个有边界的远端入口
Skill 解决的是执行规则:先查证材料,再生成源稿,校验通过后才准备提交。它本身不能证明数据库收到了哪份正文,也不能靠一句“请发布”解决身份认证、重复请求或并发编辑。
因此,MCP 只承担协议适配、令牌认证和工具输入校验,文章作者权限、Slug 唯一性、媒体权限与版本检查仍由现有业务层处理。浏览器后台继续使用自己的 Cookie 和来源校验。两种入口共享业务约束,各自承担适合其客户端的认证方式。
整个流程保留三个独立授权边界:保存本地源稿、创建或更新数据库草稿、公开发布文章。用户要求整理文章,只授权本地写作;即使已经允许保存数据库草稿,公开发布前仍须展示完整当前草稿,再取得针对文章 ID、Slug 和 revision 的确认。
9 个工具如何对应权限
服务提供四类 Scope。令牌通过认证后,各工具还会检查自己的 Scope,业务层继续验证用户角色和文章或媒体所有权。
| 工具 | Scope | 作用 |
|---|---|---|
list_posts |
posts:read |
按条件列出可管理文章 |
get_post |
posts:read |
回读正文、元数据和当前版本 |
list_taxonomies |
posts:read |
查询已有分类和标签 |
get_publication_status |
posts:read |
查询发布状态和公开地址 |
create_draft |
posts:write |
按来源创建或复用文章,新建状态为草稿 |
update_draft |
posts:write |
使用当前版本更新同源草稿 |
publish_post |
posts:publish |
按版本与明确确认参数发布草稿 |
upload_image |
media:write |
上传图片,同一用户相同内容可复用 |
delete_unreferenced_image |
media:write |
删除当前用户拥有且未被引用的图片 |
读取、写草稿和发布分别使用不同 Scope,因此可以发放只有读取能力的令牌,也可以把草稿写入与发布权限分开。工具的只读、幂等和删除风险标注帮助客户端识别操作性质,但实际授权仍依靠服务端检查。
工具同时返回可读说明和结构化结果。客户端据此判断成功、来源冲突或版本冲突,不需要从自然语言错误中猜测状态。媒体删除还要检查文章及站点头像引用,不能把“文件曾由本次上传”直接当成可以删除的依据。
用来源哈希处理重试,用 revision 处理并发
远程调用可能在数据库已写入后超时。如果客户端直接重试普通创建接口,就可能生成重复文章。Boker 为导入内容增加了来源键和来源哈希:source_key 使用仓库相对路径,source_hash 使用规范化内容的 SHA-256。
哈希覆盖标题、Slug、摘要、正文、封面、可见性、分类 ID 和标签 ID。计算前统一文本换行并排序标签 ID,避免仅因换行形式或标签顺序不同而产生无意义变化。服务端重新计算哈希,拒绝正文与哈希不一致的输入。
同一来源、同一哈希的重复创建返回既有记录;来源相同但内容变化时返回 SOURCE_CONFLICT,要求先回读再显式更新。相同 Slug 被其他来源占用时也不能覆盖。客户端须检查返回记录的状态:幂等复用不会把一篇已发布文章重新变成草稿,Skill 遇到已发布文章时只读报告。
当前本地载荷生成器先读取源稿,提取唯一 H1 作为标题,完整保留去掉 H1 后的正文。提交清单保存稳定 Slug,分类和标签 ID 则从 list_taxonomies 的真实返回值映射;随后使用与服务端一致的算法生成来源哈希。这样既不需要在仓库保存生产 ID,也不需要手工拼接哈希。
更新是另一类问题。读到 revision 之后,别人可能已经改过文章,因此更新和发布都必须携带当前 revision。数据库写入条件同时检查目标版本,成功后递增版本;不匹配则返回 REVISION_CONFLICT。客户端必须重新回读和审阅,旧版本的发布确认不能沿用。
发布工具还要求 confirm_slug 与目标 Slug 完全一致,并要求 confirm 为字面量 PUBLISH。这些参数是服务端可验证的确认条件;“完整展示草稿并获得人的同意”由 Skill 和客户端工作流落实,服务端不能从一个字符串判断人是否真正审阅过文章。
浏览器后台修改导入文章的来源内容或元数据时,会保留来源键并清空来源哈希,以表达数据库内容已经偏离上次导入稿;仅改变发布状态时则保留来源哈希。这样,后续导入不会凭旧哈希误判内容一致。公开可见还要求状态为 PUBLISHED 且可见性为 PUBLIC;Skill 因此在发布前检查可见性,发布后同时核对数据库状态与公网页面。
Streamable HTTP 入口的认证与代理边界
MCP 入口位于现有应用的 /mcp,使用 Streamable HTTP 和无状态请求处理。它没有独立的发布进程;请求通过身份认证后构建工具服务,调用同一套文章与媒体业务函数。
远程请求使用 Bearer PAT。令牌创建时只显示一次原始值,数据库保存其哈希;认证会检查有效期、吊销状态及关联用户是否启用。无效认证返回 HTTP 401,Scope 不足则返回工具级 FORBIDDEN。可以单独吊销访问令牌,无须借此修改后台登录密码。
入口按顺序检查生产 HTTPS、允许的 Host,再验证 PAT。Host 来自站点配置及显式允许列表,客户端更换接入主机时必须同步核对。生产环境还会根据代理传来的协议判断请求是否使用 HTTPS,因此代理必须正确还原外部协议,并确保相关请求头来自受信任的上一跳。
这几层约束分别解决传输、目标主机和调用身份问题。Tailnet 中可达不代表自动拥有文章权限;认证成功也不会跳过文章所有权或 revision 检查。排障时应分别检查网络可达性、HTTPS 与 Host、令牌认证、工具权限,才能解释请求停在哪一层。
从单元测试到真实客户端的证据
仓库中的验证分成几层。令牌与来源哈希单元测试覆盖纯逻辑;数据库集成测试覆盖幂等创建、来源与 Slug 冲突、版本冲突,以及媒体去重和引用权限。内存传输下的 SDK Client 测试检查工具发现、输入校验、Scope 拒绝和结构化错误,HTTP 边界测试则检查认证、Host 与生产 HTTPS 限制。
真实协议脚本进一步启动临时 PostgreSQL 和 Next.js,通过 SDK 的 Streamable HTTP Client 执行上传图片、创建草稿、回读、显式发布、查询状态及访问公开页面。仓库开发记录确认,这条本地链路已连续运行两次通过,并检查测试服务和临时资源的清理。它证明了测试环境里的完整协议链路,不等于在生产站点完成同样的写操作。
生产接入的证据更具体:用户从新 shell 启动 Codex 后,客户端显示 boker 正常连接并发现 9 个工具;本文整理期间又调用了一次只读 list_taxonomies,成功取得结构化分类与标签。这里验证的是生产连接、工具发现和分类读取,没有据此推断上传、创建或发布已经在生产逐项验收。
Node.js 版本故障发生在迁移之前
第一次生产切换使用 Node.js 18.19.1。执行数据库迁移命令时,Prisma CLI 在 styleText 调用处抛出 TypeError,迁移没有执行。切换脚本报告失败,并在可能时恢复旧应用。这个现象首先指向运行时与 CLI 的兼容性,不能解释成数据库已经迁移了一半,更不能归因于 MCP 工具实现。
第二次运行前切换到生产 Node.js 24.21.0。日志显示 Prisma 成功加载配置与 schema,发现两个迁移,并应用 202609210001_mcp_publishing,最后报告全部迁移成功。这次恢复有明确的运行时变化和迁移结果,文章只据此说明该环境中的故障与恢复,不把它扩展成所有 Node.js 版本的兼容性结论。
同次脚本还配置了 Tailscale Serve。末尾的即时本机连接检查曾失败,脚本仍报告已经切换并保留回滚产物;随后用户还观察到服务处于 failed 状态。这说明“切换成功”一行不足以证明服务持续可用。已有材料没有确定该次服务失败的根因,因此保留现象,继续用服务状态和真实 MCP 调用核验恢复结果。
Tailscale Serve、代理绕过与 shell 环境继承
本次 MCP 接入使用的抽象链路是:Codex 访问 Tailnet HTTPS 入口,经 Tailscale Serve 代理到本机应用,再进入 MCP 认证和业务处理。它补齐远程客户端需要的 HTTPS 接入层,也使客户端网络路径成为排障的一部分。
客户端如果把 HTTP(S) 请求交给系统代理,访问 Tailnet 主机时就需要检查直连例外。可为该主机配置 NO_PROXY、no_proxy 或等价规则,避免请求被送到无法访问 Tailnet 的代理。这里应按实际目标设置规则,不需要把真实内部地址写进文章。
环境变量还有进程继承边界。把令牌变量和代理绕过规则写入 shell 启动文件,并不会自动修改一个已经运行的 Codex 进程。用户最终在新的 shell 窗口确认令牌变量已加载,检查只输出 Boker token: loaded;由这个环境启动的新 Codex 会话随后成功连接并发现全部工具。
这组结果支持“新环境下接入成功”,不能单独证明此前故障只由代理引起。服务状态、客户端变量是否加载、客户端是否继承变量,需要各自检查。验证令牌可用性时也只需报告是否加载及认证结果,无须打印实际值。
已验证范围与后续边界
截至本文整理,生产迁移已成功,真实 Codex 已完成 9 工具发现与只读分类调用复验;本地真实 SDK 发布链路的通过结果来自仓库开发记录。本次写作与载荷校验只处理源稿,没有因连接成功而执行生产媒体写入、创建草稿或公开发布。
当前 MCP 不提供创建分类、创建标签、删除文章或修改站点设置的工具,也不能直接更新已发布文章。公开发布仍须完整回读当前数据库草稿,核对正文与元数据,并对当前 revision 单独确认;发布后还要检查真实公开页面,不能只依据工具返回的 URL 报告成功。
服务器提示更新后需要重启,是用户留待后续处理的独立运维事项。它没有被当作此次 MCP 接入失败的原因,也不能代替服务与客户端的实际验收。今后调整运行时、迁移、代理或令牌配置时,仍需重新验证对应边界。