Skip to main content
Glama
yinxianwei
by yinxianwei
README.md
# weknora-mcp

极简 MCP 写入服务:让 **Grok** 把一个 URL 填进自定义连接器,就能把文档写进你的
**WeKnora** 知识库。

只有一个工具 `write_wiki(title, content)`,只做写入,不做查询。

---

## ⚠️ 注意事项(部署前必读)

下面每一条都踩过坑或会直接导致服务不可用,请先看完再动手。

| 事项 | 说明 |
|---|---|
| **只能单实例** | SQLite outbox 和后台 worker 都假设只有一个写者。不要加 `--workers`,也不要起第二个实例,否则会重复写入。 |
| **`.env` 靠当前目录定位** | 程序从工作目录读 `.env`。systemd 的 `WorkingDirectory` 绝不能省,否则会以「缺少 WEKNORA_API_KEY」启动失败。 |
| **仓库放 `/home` 或 `/root` 下时** | systemd unit 里的 `ProtectHome` 必须保持 `no`,否则服务读不到代码和 `.env`。放 `/opt`、`/srv` 时不受影响。 |
| **`ReadWritePaths` 目录必须先存在** | 先 `mkdir -p <repo>/data` 再启动,否则 systemd 挂载失败、直接拒绝启动。 |
| **nginx 必须加 `proxy_buffering off`** | 否则 Streamable HTTP / SSE 会被缓冲,Grok 一直转圈。 |
| **token 会进 nginx access log** | 默认 `log_format` 记的是 `$request`,含 query string。建议改用 `$uri`。 |
| **token 本身就在 URL 里** | 它会留在 Grok 连接器配置和浏览器历史中,务必用长随机串,泄露了就换一个。 |
| **服务只监听 `127.0.0.1`** | 别直接绑 `0.0.0.0` 又不填 `MCP_ALLOWED_HOSTS`,那等于把写入接口裸奔在公网。 |
| **`data/` 里有你的文档原文** | 权限收紧到 `700`,不要提交、不要备份到公开位置。 |
| **只写不读** | 没有任何查询接口,Grok **无法确认写入结果**。写入失败得自己看 `/healthz` 和 outbox CLI。 |
| **停止要留时间** | 停止时 worker 可能正在 POST,unit 的 `TimeoutStopSec=30` 是给它的收尾时间,改小了会中断正在写入的记录。 |
| **两个刻意的重复窗口** | 极端情况下可能重复写一条,这是换取一半代码量的取舍,见「状态与重试」。 |

## 为什么是「异步 + 本地队列」

Grok 对 MCP 工具调用有超时限制。一旦超时,它就会**重发**同一个调用,于是同一篇
文档在知识库里出现两遍 —— 这是最常见的投诉。

本服务的解法是:**让工具调用路径上不存在任何网络请求**。

```
Grok ──write_wiki──> 写入本地 SQLite ──> 立刻返回 accepted(<10ms)
                          │
                          └──> 后台 worker ──> POST WeKnora
                                              成功 → done
                                              失败 → 每 30s 重推,20 次后 failed
```

由此得到两个保证:

| 问题 | 解法 |
|---|---|
| Grok 超时重发导致重复 | 调用瞬间返回,不触发超时;再加 `sha256(kb_id+title+content)` 幂等键兜底 |
| WeKnora 挂了文档丢失 | 先落本地 SQLite,worker 自动重推,进程重启后继续 |

---

## 快速开始

需要 Python 3.12+ 和 [uv](https://docs.astral.sh/uv/)。

```bash
uv sync                      # 装依赖(含 dev 组)
cp .env.example .env         # 然后填写 .env
uv run python -m app --check   # 配置自检,不启动
uv run python -m app           # 启动服务
```

`.env` 里三样必填:

```bash
WEKNORA_API_KEY=sk-...
WEKNORA_DEFAULT_KB_ID=kb_...          # 唯一的写入目标
MCP_TOKEN=$(openssl rand -hex 32)     # 至少 32 字符
```

启动成功后日志会打印:

```
监听 http://127.0.0.1:8765/mcp(Grok 填写 https://<域名>/mcp?token=<MCP_TOKEN>)
```

---

## 接入 Grok

在 Grok 的 `grok.com/connectors` → New Connector → Custom,填入:

```
https://你的域名/mcp?token=<MCP_TOKEN>
```

Grok 只支持 Streamable HTTP / SSE,所以必须经 HTTPS 暴露到公网。

> 本服务本身**不监听公网**,只监听 `127.0.0.1:8765`,由你的反向代理转发。

### nginx 配置(本项目不自动安装,只给文档)

```nginx
location /mcp {
    proxy_pass http://127.0.0.1:8765/mcp;
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Connection        "";

    # ↓↓↓ 这三行是 Streamable HTTP / SSE 的命门,漏了就一定出问题
    proxy_buffering off;
    proxy_cache     off;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;

    chunked_transfer_encoding on;
    add_header X-Accel-Buffering no;
}
```

两个提醒(已汇总到顶部「注意事项」):`proxy_buffering off` 必须加,
token 会进 nginx access log。

如果你把 `MCP_ALLOWED_HOSTS=你的域名` 填上,服务会启用 Host 白名单校验,
非白名单 Host 直接返回 421。留空则关闭该校验。

> 注意:不要把服务直接绑到 `0.0.0.0` 又不填 `MCP_ALLOWED_HOSTS`。

---

## 部署:Linux + systemd

仓库里带了一份可直接用的 unit 文件:`deploy/weknora-mcp.service`。

### 0. 前提

服务器上装好 Python 3.12+。

依赖由 `uv` 管理。venv 建好后就自包含了,**运行期不需要 `uv`**,
只在建环境 / 更新时用一次。

### 1. 准备目录(用默认用户,不新建账号)

**不需要 `useradd`。** 直接用你要运行服务的那个用户就行,unit 里也**不写 `Group=`**,
systemd 会自动使用该用户的默认主组。

```bash
mkdir -p /opt/weknora-mcp        # 换成你自己的部署目录
```

如果部署目录在 `/root` 或 `/home` 下,就由对应的归属用户运行,
unit 里的 `User=` 与该用户保持一致即可。

### 2. 放代码、建 venv

下面都假设你已经以「要运行服务的那个用户」登录。`uv` 会装到 `~/.local/bin`,
所以要用登录 shell(`bash -lc`)才找得到它:

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh

git clone <你的仓库地址> /opt/weknora-mcp
cd /opt/weknora-mcp
uv sync
```

> 换过部署目录的话,unit 文件顶部写明了哪 5 处要一起改,并给了 sed 命令。

### 3. 写 .env、建 data 目录

```bash
cp .env.example .env
chmod 600 .env
vi .env                          # 填 API Key / KB ID / MCP_TOKEN

mkdir -p data
chmod 700 data                   # 里面有你的文档原文,别对别人开放

uv run python -m app --check     # 配置自检,通过后再继续
```

**不需要配 `EnvironmentFile=`。** `.env` 由程序自己从 `WorkingDirectory` 读取,
这就是「配置放在项目根目录」的含义。所以 `.env` 必须可被服务用户读到,
且 `data/` 必须可写(**这个目录必须先存在**,否则 unit 的 `ReadWritePaths` 会让
systemd 拒绝启动)。

### 4. 安装并启动

```bash
sudo cp deploy/weknora-mcp.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now weknora-mcp
```

### 5. 查看状态与日志

```bash
systemctl status weknora-mcp
journalctl -u weknora-mcp -f                    # 实时日志
journalctl -u weknora-mcp -n 100 --no-pager     # 最近 100 行
```

如果 `ExecStartPre` 的配置自检失败,服务会直接进入 `failed` 而不会带病运行,
具体原因看 `journalctl -u weknora-mcp -n 50`。

### 6. 更新代码

```bash
cd /opt/weknora-mcp
git pull
uv sync
sudo systemctl restart weknora-mcp
```

只改 `.env` 的话不用 `systemctl daemon-reload`,直接 `restart` 即可
(只有改 unit 文件本身才需要 reload)。

---

## 调用行为(给模型看的语义)

`write_wiki` 的返回永远是「已受理」,不是「已写入」:

```json
{
  "ok": true,
  "status": "accepted",
  "task_id": "w-20260912-7f3a1c",
  "title": "会议纪要",
  "characters": 1234,
  "message": "已受理并排队写入知识库,请勿重复调用本工具。"
}
```

重复调用同一份内容时:

```json
{
  "ok": true,
  "status": "accepted",
  "task_id": "w-20260912-7f3a1c",
  "deduplicated": true,
  "message": "同一内容已受理过,本次未重复入队,请勿再次调用本工具。"
}
```

参数非法是唯一会返回 `ok: false` 的情况:

```json
{ "ok": false, "status": "rejected", "error": "title_required", "message": "title 不能为空" }
```

**`ok: true` 只表示「传输层收到了」**,业务状态永远放在 `status` 字段里。

---

## 状态与重试

| 状态 | 含义 | 会重推吗 |
|---|---|---|
| `pending` | 已入队,等待推送 | ✅ 每 30s |
| `done` | POST 成功(2xx) | ❌ |
| `failed` | 连续失败 20 次(≈10 分钟) | ❌ 永久终止,需人工 |

两个**刻意接受**的重复窗口(换取一半的代码量):

- POST 成功后、写 `done` 之前进程崩溃 → 重启后重推,可能重复一条
- POST 超时但 WeKnora 其实已收到 → 重推,可能重复一条

如果实际中真的遇到,再补「重推前按标题查一次」即可。

---

## 运维:outbox CLI

乐观确认的代价是失败会静默,所以必须有地方看。**这些不是 MCP 工具,Grok 调不到。**

```bash
uv run python -m app outbox list                   # 概览
uv run python -m app outbox list --status failed   # 只看失败的
uv run python -m app outbox show <task_id>         # 看完整内容与错误
uv run python -m app outbox retry <task_id>        # 重置为 pending,让 worker 重推
```

健康检查(免鉴权):

```bash
curl http://127.0.0.1:8765/healthz
# {"status":"ok","pending":0,"done":12,"failed":1,...}
```

`failed > 0` 时 `status` 会变成 `degraded`,可直接接监控告警。

---

## 配置项

| 变量 | 默认值 | 说明 |
|---|---|---|
| `WEKNORA_BASE_URL` | `https://weknora.weixin.qq.com/api/v1` | WeKnora API 地址 |
| `WEKNORA_API_KEY` | 必填 | WeKnora 凭证 |
| `WEKNORA_DEFAULT_KB_ID` | 必填 | 唯一目标知识库 |
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8765` | 监听地址 |
| `MCP_PATH` | `/mcp` | MCP 端点路径 |
| `MCP_TOKEN` | 必填 | URL token,≥32 字符 |
| `MCP_DB_PATH` | `./data/weknora-writes.db` | outbox 数据库 |
| `MCP_JSON_RESPONSE` | `true` | POST 回 JSON(推荐)而非 SSE 流 |
| `MCP_ALLOWED_HOSTS` | 空 | 逗号分隔的 Host 白名单;空=关闭校验 |
| `MCP_POST_TIMEOUT` | `10` | 单次推送超时(秒) |
| `MCP_RETRY_INTERVAL` | `30` | 重推间隔(秒) |
| `MCP_MAX_ATTEMPTS` | `20` | 连续失败上限(≈10 分钟) |
| `MCP_MAX_CONTENT_CHARS` | `100000` | 正文长度上限 |
| `MCP_LOG_LEVEL` | `info` | 日志级别 |

**必须单进程运行。** SQLite outbox 与 worker 都假设只有一个写者,
不要用 `--workers 2` 或同时起多个实例。

---

## 开发

```bash
uv sync                          # 装依赖(含 dev 组的 pytest / ruff)
uv run ruff check .              # 静态检查
uv run ruff check --fix .        # 自动修复(改完务必重跑测试)
uv run pytest                    # 54 个测试
uv run python -m app --check     # 配置自检
```

`tests/test_server.py` 会真的起一个 HTTP 服务,走完整的 MCP 握手,
并覆盖三个最容易翻车的点:nginx 反代的 Host 头、token 鉴权、幂等去重。
`tests/test_weknora.py` 用 `MockTransport` 盯住发往 WeKnora 的请求形状
(尤其是 `X-API-Key` 这个头,写错会得到 401)。

### 防泄漏钩子

仓库带了一个 pre-commit 钩子,拦住 `.env`、密钥样式和大文件。
它在 `.git/` 里不会被版本控制,所以要用仓库内这份:

```bash
git config core.hooksPath .githooks
```

> 注意:`core.hooksPath` 是本地配置,重新 clone 或删掉 `.git` 后需要重新设一次。

---

## 目录结构

```
app/              服务代码(7 个模块)
tests/            单元测试 + 真实 HTTP 的 MCP 端到端测试
deploy/           systemd unit 文件(文档用途,不自动安装)
.githooks/        pre-commit 防泄漏钩子
pyproject.toml    依赖、打包与 ruff 配置
uv.lock           锁定版本
README.md
LICENSE           MIT
```

---

## License

[MIT](LICENSE)