Hugo MCP:给文件型博客加一个受控的 AI 发布入口

写博客时,真正麻烦的往往不是把文章写出来,而是写完以后那一串重复动作:打开后台、粘贴 Markdown、调整分类和标签、上传图片、检查发布结果。博客从 Typecho/WordPress 迁到 Hugo 后,这个后台没有了,文章只剩下 content/posts 里的 Markdown 文件。

那能不能让 Codex 直接管理 Hugo?可以,但我不想把服务器 shell 权限交给 AI,也不想为了一个个人博客再装一套 CMS。最后采用的方案,是在 Hugo 外面加一个很薄的 MCP 服务:AI 调用的是受限的文章工具,服务负责校验、写文件、备份、审计和构建。

这篇记录的是实际部署方案,不是另起一个后台系统。

它能做什么

Hugo MCP 提供 JSON-RPC 2.0 接口,当前主要分四类工具:

  • 读取:hugo_list_postshugo_get_post
  • 文章:hugo_create_drafthugo_update_drafthugo_publish_posthugo_unpublish_posthugo_delete_post
  • 媒体:hugo_upload_mediahugo_list_mediahugo_delete_media
  • 运维:hugo_migrate_post_bundlehugo_build

新文章默认创建成 Page Bundle:

1
2
content/posts/<slug>/index.md
content/posts/<slug>/image.webp

旧文章的 content/posts/<slug>.md 也可以继续读取和更新,需要时再通过迁移工具转成 Page Bundle。

它不提供 CMS 后台、评论管理、用户系统、全文搜索,也不负责 Git 提交。Hugo 还是展示层,Markdown 还是内容源,MCP 只是给 AI 一个有边界的写入口。

为什么直接做成 MCP

参考 Typecho AgentBridge 的思路,MCP 比单独写一个“AI 发布脚本”更适合这个场景:客户端可以发现工具和参数,调用过程有统一的 JSON-RPC 结构,也可以把权限、鉴权和并发保护集中放在服务端。

但 Hugo 和 Typecho 的边界不一样。Typecho 有数据库和后台用户;Hugo 没有数据库,文章就是文件。因此 Hugo MCP 不模拟一个虚假的 CMS,而是直接围绕文件系统做限制:只允许访问文章目录,只接受受控的 slug,只在需要时调用 Hugo 构建。

实际链路如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Codex / 其他 MCP 客户端
        │ JSON-RPC 2.0 + Bearer Token
HTTPS 反向代理
Hugo MCP 容器
  ├─ content/posts/*.md 或 Page Bundle
  ├─ backups / audit / idempotency / trash
  └─ hugo build
Hugo public/ → Nginx 静态站点

AI 得到的是“文章工具”,不是服务器终端,也不需要知道 SSH 私钥。

第一步:按 Docker 目录规范部署

这套服务放在 docker03 上,Compose 文件和运行数据分开:

内容 路径 作用
Compose 文件 /data/docker/service/hugo-mcp/ docker-compose.yml、构建上下文
Hugo 站点 /data/docker/appdata/hugo/data/site/ hugo.tomlcontent/public/
MCP 运行数据 /data/docker/appdata/hugo-mcp/ token、备份、审计、幂等记录、trash

MCP 数据目录不能放进博客 Git 仓库。尤其是 token、审计日志和回收站,不能因为同步文章而一起提交。

Compose 的关键挂载类似这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
services:
  mcp:
    build: .
    container_name: hugo_mcp
    restart: unless-stopped
    user: "0:0"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    read_only: true
    tmpfs:
      - /tmp:size=64m,noexec,nosuid,nodev
    environment:
      HUGO_SITE_ROOT: /site
      HUGO_MCP_DATA_ROOT: /data
      HUGO_MCP_TOKEN_FILE: /data/token
      HUGO_BIN: /usr/local/bin/hugo
    ports:
      - "127.0.0.1:8095:8080"
    volumes:
      - /data/docker/appdata/hugo/data/site:/site
      - /data/docker/appdata/hugo-mcp:/data
      - /usr/local/bin/hugo:/usr/local/bin/hugo:ro

8095 只绑定本机回环地址。外部客户端通过 HTTPS 反向代理访问,不能把 MCP 端口直接丢到公网。

第二步:配置 Token 和入口

服务首次启动会在运行数据目录生成随机 token。token 只保存在服务器上,客户端使用环境变量注入:

1
2
3
[mcp_servers.hugo]
url = "https://blog.example.com/index.php/action/agent-mcp"
bearer_token_env_var = "HUGO_MCP_TOKEN"

实际公开入口是博客域名下的兼容路径,反向代理再把请求转给容器。没有 Bearer Token 的请求直接返回 401;服务不提供后台登录页,也不保存 GitHub 凭据。

连接后先执行 initialize,再执行 tools/list。客户端应该以服务端返回的工具合同为准,不要把工具参数写死在提示词里。

第三步:跑通一次完整写作流程

一次发布不是直接调用“发布”按钮,而是固定走下面的顺序:

1
2
3
4
5
6
7
8
hugo_list_posts / hugo_get_post
        ↓ 取得文章和 revision
hugo_create_draft 或 hugo_update_draft
        ↓ 检查标题、分类、标签、正文
hugo_upload_media(需要图片时)
        ↓ 将返回的相对引用写入正文
hugo_publish_post
        ↓ 调用 Hugo 构建

创建草稿的 JSON-RPC 请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "hugo_create_draft",
    "arguments": {
      "title": "文章标题",
      "slug": "article-title",
      "content": "正文内容",
      "categories": ["项目实战"],
      "tags": ["MCP", "自动化"],
      "request_id": "draft-20260921-001"
    }
  }
}

调用返回文章 revision。发布时把这次返回的 revision 原样传给 hugo_publish_post,服务构建完成后返回新的 revision。

修改已发布文章:revision 不能省

每篇文章的 revision 是当前 Markdown 文件的 SHA-256。所有会改变文章的工具都要求 expected_revision

1
2
3
4
5
6
7
8
9
{
  "name": "hugo_update_draft",
  "arguments": {
    "slug": "example-post",
    "expected_revision": "刚刚读取到的 sha256",
    "tags": ["MCP", "自动化"],
    "request_id": "post-update-20260921-001"
  }
}

如果期间有人在本地编辑过文件,revision 不匹配,服务会拒绝写入。正确处理方式是重新读取文章,再根据新内容提交;不要绕过检查。

这个保护解决的是个人博客里很容易遇到的情况:电脑上的编辑器、Codex 和服务器上的同步任务同时碰了一篇文章。没有 revision 的话,最后一次写入会静默覆盖前面的修改。

删除、恢复和重复请求

写操作还有两个保护:

  1. 每次变更前先生成备份,删除文章时移动到私有 trash,不直接销毁。
  2. 每次写操作必须带唯一 request_id,结果会保存到幂等记录。网络超时后可以用同一个 ID 重试,不会重复创建或重复删除。

审计日志使用 JSONL 保存调用时间、工具名、request_id、slug 和结果。它不是完整的数据库事务,但足够用来判断一次超时请求到底有没有落盘。

图片、反向代理和几个坑

媒体上传不接受调用者本机路径,也不抓取远程 URL,而是通过 Base64 传给 MCP。当前只允许 JPEG、PNG、WebP、GIF,并检查文件签名;默认单文件上限为 5 MiB,请求体上限为 8 MiB。

几个实际要注意的点:

  • Hugo MCP 不负责 CDN,也不会替你压缩图片。
  • 公开入口必须走 HTTPS,8095 不应该直接暴露。
  • 反向代理需要完整转发 POST、Authorization 和响应体。
  • 同一个实例内有写锁;多副本部署还需要外部锁和共享运行数据,不能简单复制容器。
  • taxonomy 管理不是当前工具的一部分,标签整理仍然是内容维护动作。

GitHub 放在哪一层

GitHub 不属于 Hugo MCP 的运行时依赖。博客内容维护在私有仓库,Hugo MCP 单独开源;需要提交源码时,由客户端调用 GitHub MCP 或 GitHub Actions 完成。

这样做把两种权限拆开了:MCP token 只能操作博客内容,GitHub Token 只交给 GitHub 侧的同步流程。MCP 服务本身不保存 GitHub 凭据,也不会因为 GitHub 暂时不可用而阻塞本地文章编辑。

测试范围与后续

当前已经验证了读取、创建草稿、更新、发布、撤回、软删除、Page Bundle 媒体和 Hugo 构建流程,也专门测试了 revision 冲突、重复 request_id、非法路径和未授权请求。

当前版本明确不做 CMS 后台、评论、全文搜索、Git 提交和多实例共享锁。对于个人博客,这个边界反而比较舒服:Hugo 继续保持简单,GitHub 继续保存内容历史,MCP 只负责把 AI 的动作限制在文章操作里。

项目源码、工具合同和部署说明:

这套方案的核心不是“让 AI 拥有后台权限”,而是把博客后台拆掉以后,重新提供一条可审计、可回滚、能做并发检查的内容写入路径。

使用 Hugo 构建
主题 StackJimmy 设计