Skip to main content
Glama
Hone125

mcp-guarded-toolkit

by Hone125
README.md
# mcp-gatekeeper

一个基于 **MCP(Model Context Protocol)** 的本地工具服务:把一份示例数据库和一份公版文本库
开放成 6 个工具。其中「让模型自己写 SQL 查数据库」这条链路必须真的安全 ——
所以它配了**三层 SQL 护栏**、**工具级 scope 鉴权**、**有界重写状态机**和**可复核的数字口径**。

它的用途是**技术验证**:证明这套工程约束是可迁移的,而不是绑在某一份数据上的特例。

- 只用公开、可再分发的数据:示例库 + 公版中文文本(逐篇出处见 [NOTICE.md](NOTICE.md))
- **不联网、不需要模型凭据**就能跑完全部测试与自检(自检里靠词表的那几项会判 `SKIP`,
  见「已知边界」——`SKIP` 是「没查」,不是通过)
- 报告里每个数字都能指出「脚本名 + 口径」,没有估计值
- 需要模型凭据的那条链路**已经跑通**(2026-09-18 的评测;2026-09-22 又在并发下重跑了一遍对账),
  数字落盘在 [RESULTS.md](RESULTS.md) 第二节与 [reports/ledger_under_concurrency.md](reports/ledger_under_concurrency.md)
- **本仓库不放凭据**(`.env` 也不放,理由见 [BLOCKERS.md](BLOCKERS.md) 卡点 1),所以直接重跑
  需要凭据的脚本会一律报「未完成」(退出码 5 + 一份说明),**不编数字**。
  本轮真跑通的那几次,凭据是以**进程环境变量**注入的,没落仓库盘

## 快速开始

```bash
# 1) 单元测试(零网络、零模型调用)
python -m pytest -q

# 2) 构建数据:示例库(.sql → .db)与文本库(切词 → 全文索引)
python experiments/01_build_db.py
python experiments/02_build_kb.py

# 3) 安全层与协议层:鉴权矩阵、护栏负例矩阵、真实 stdio 握手
python experiments/10_auth_test.py
python experiments/11_guardrail_test.py
python experiments/08_mcp_smoke.py

# 4) HTTP 入口与并发:真起服务、真 TCP 冒烟;以及并发度的实测读数
python experiments/30_http_smoke.py
python experiments/29_concurrency_bench.py

# 5) 一条命令回答「现在能不能交出去」
python tools/check.py
```

`python tools/check.py` 会依次跑单元测试、台账式自检、交付物核验、收尾自检,
**退出码 = 卡在第几步**(0 = 全部通过)。只想看它要跑什么:`python tools/check.py --list`。

### 起服务端

```bash
python -m mcp_server.server --check     # 只做启动自检:注册的工具与 scope 表是否一致
python -m mcp_server.server             # 真正拉起 stdio 服务端

python -m mcp_server.http_api --check   # HTTP 侧的自检:路由名集合 == TOOL_SCOPES 键集合
python -m mcp_server.http_api           # 拉起 HTTP 服务(默认 127.0.0.1:8000)
python -m mcp_server.http_api --port 0  # 由系统分配端口,启动行会打印真实端口
```

stdio 服务端的 **stdout 是协议通道**,所以人话一律走 stderr —— 打印一行启动信息到
stdout 会让握手直接报 `Invalid JSON`(这个 bug 只有真实握手能抓到,见 `PROGRESS.md` 阶段 3)。
HTTP 侧沿用同一条规矩:人话走 stderr,端口也打在那儿(`[http] 监听 127.0.0.1:<port>`)。
服务端**自己先 bind 好 socket 再交给 uvicorn**,所以端口是确定的,不存在「先探测空闲端口、
启动时被抢走」的竞态。

起好之后,`/docs` 是 OpenAPI 自动生成的交互文档,`/openapi.json` 是机器可读的那份。

## 工具集

6 个工具、3 个 scope。scope 挂在**工具**上而不是「用户」上,所以换一个客户端、
换一次会话,权限判定都一样。

| 工具 | scope | 做什么 |
|---|---|---|
| `list_tables` | `db:read` | 列出所有表和视图 |
| `get_schema` | `db:read` | 看某张表的列与类型 |
| `run_sql` | `db:query` | 执行一条只读查询(过三层护栏) |
| `ask_database` | `db:query` | 自然语言提问 → 模型写 SQL → 护栏 → 执行 → 失败则重写 |
| `search_passages` | `kb:read` | 在公版文本库里检索段落,返回原文片段与偏移量 |
| `get_passage` | `kb:read` | 按偏移量取回原文 |

调用工具要带一个 JWT,令牌里带着 scope 列表:

```python
from mcp_server import auth
token = auth.make_token("demo-user", scopes=["db:read", "kb:read"])
```

`ask_database` 是唯一需要模型凭据的工具;其余 5 个不需要。

### 同一批工具的 HTTP 入口

`mcp_server/http_api.py` 把上面这 6 个工具挂成 HTTP 路由。路由是照着 `auth.TOOL_SCOPES`
**现生成**的(不是第二份工具清单),请求体就是那个工具的 kwargs(含 `token`),
响应体就是工具原本的返回值 —— 同一份业务逻辑,两个传输层。

| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/tools/<工具名>` | 调一个工具;上表那 6 个名字各有一条路由 |
| GET | `/tools` | 工具名 + 所需 scope 的清单 |
| GET | `/healthz` | 存活探针 |
| GET | `/docs` | OpenAPI 交互文档(FastAPI 自带,`/openapi.json` 是机器可读的那份) |

状态码不是一刀切:拒绝 → 403(`code` 原样带回)、未知工具 → 404、参数绑不上 → 400,
**被 SQL 护栏拦下的查询是 200** —— 那是业务结论(请求被正确执行并给出了结论),
报 4xx 会让「护栏工作正常」看起来像「调用失败」。完整映射见
`mcp_server/http_api.py` 的 `status_for()`,逐条断言在 `tests/test_http_api.py`。

并发调用走 `mcp_server/concurrency.py`:同步阻塞的工具函数一律 `asyncio.to_thread`
挪出事件循环,`Semaphore` 限并发,`wait_for` 超时降级成一条结果记录而不是拖垮整批。
**为什么必须挪**、以及不挪会怎样(实测并发度退化成 1),见 `reports/concurrency.md`。

## Text2SQL 状态机:手写版与 LangGraph 版

同一个「有界重写」状态机有两套实现,**并列保留**,不是替换:

| | 手写版 `mcp_server/t2sql_core.py` | LangGraph 版 `mcp_server/t2sql_graph.py` |
|---|---|---|
| 控制流 | 一个 `for` 循环 + `if` / `continue` / `return` | 4 个节点 + 2 个路由函数 + 1 条回边 |
| 谁在用 | MCP 的 `ask_database`、HTTP 入口、评测脚本(默认) | 测试(与手写版跑同一批断言);评测脚本的 `--impl graph` |
| 依赖 | 无框架 | `langgraph==1.2.16` |

两版共用提示词、取 SQL、失败批注、预算常量(最多 3 次模型调用)、护栏执行和返回体,
**只有控制流不同**。`tests/test_t2sql_state_machine.py` 里所有调用 `run()` 的用例对两版各跑一遍;
另有一组对照用例,要求同一剧本下两版发给模型的每一轮消息、返回体逐字相同。

接上真模型的对照(2026-10-23,`deepseek-chat`,提示词 v3,同一天两版各跑一遍 36 题):

```powershell
python experiments/09_text2sql_eval.py --versions v3 --impl hand --tag 20261023
python experiments/09_text2sql_eval.py --versions v3 --impl graph --tag 20261023
python experiments/33_impl_compare.py --tag 20261023
```

两版都是可答题 31/32、不可答题拒答 4/4、每题平均 1.00 次调用,**逐题判定翻转 0 道**;
有 3 道题两版写出的 SQL 文本不同但都判对,那是模型输出自己的抖动。明细见
[reports/impl_compare.20261023.md](reports/impl_compare.20261023.md)。
这组数和 [RESULTS.md](RESULTS.md) 第二节 2026-09-18 那一轮**不是同一个模型、同一个端点**,不能拿来直接比。

下图由 `GRAPH.get_graph().draw_mermaid()` 直接导出:实线是固定的边,虚线是路由函数决定的条件边。
测试会核对这张图和代码现画出来的是否一致。

```mermaid
---
config:
  flowchart:
    curve: linear
---
graph TD;
	__start__([<p>__start__</p>]):::first
	prepare(prepare)
	generate(generate)
	execute(execute)
	repair(repair)
	__end__([<p>__end__</p>]):::last
	__start__ --> prepare;
	execute -.-> __end__;
	execute -.-> repair;
	generate -.-> __end__;
	generate -.-> execute;
	generate -.-> repair;
	prepare --> generate;
	repair --> generate;
	classDef default fill:#f2f0ff,line-height:1.2
	classDef first fill-opacity:0
	classDef last fill:#bfb6fc
```

### 为什么先手写、再迁移

- **先手写,是为了先把行为钉死。** 这个状态机只有一条循环、三种失败、一个上限,
  手写一遍最容易看清每一步发生了什么;再用假模型把控制流测透(重写几次、批注怎么写、
  什么时候停)。这批测试就是行为规格。
- **再迁移,迁移才有可判定的标准。** 规格在手,迁移就是「换控制流、规格不变」——
  同一批测试两版都要过,而不是「看起来能跑」。
- **反过来先上框架**,框架用法和业务逻辑同时在变,测试红了分不清是框架用错了还是逻辑写错了。

### 两种实现的取舍

LangGraph 带来的能力,以及本仓库**实际用上了哪些**:

| 能力 | 是什么 | 本仓库 |
|---|---|---|
| 持久化(checkpointer) | 每走一步把 State 存档;同一个会话(`thread_id`)下次接着用 | 接了,只用于多轮追问 `t2sql_graph.ask()`,测试里挂 `InMemorySaver`:同一个 `thread_id` 的第二问会带上之前的问题和最终 SQL。每问独立的字段(`history` 等)每问开头清空,跨轮只保留 `turns`(理由见 `mcp_server/t2sql_graph.py` 文件头)。没有接持久化到磁盘的存档 |
| 中断恢复(interrupt) | 在某个节点前后停下,等人确认或修改 State 再继续 | 未用 |
| 可视化 | 从代码直接导出流程图 | 用了:上面那张图 |
| 流式输出(stream) | 每个节点跑完就产出一次更新,不必等整张图跑完 | 未对外提供 |

LangGraph 的代价:

- **多一个依赖**:`langgraph` 本身之外还带进 21 个传递依赖,其中 8 个是编译扩展
  (明细见 `requirements.txt` 文件头);以后升级版本要跟着回归。
- **多一层抽象**:原来一个函数从头读到尾;现在要对照 State 定义、节点、路由、组图四处,
  还要先懂 reducer、context、`recursion_limit` 这些框架概念。
- **出问题要隔着框架调试**:异常栈里多出框架自己的调用层;框架参数的口径要实测才知道 ——
  例如走满预算的 9 个节点,`recursion_limit` 设 9 会报错、设 10 才够(见 `t2sql_graph.py` 的注释),
  而这个版本的默认值是 10007,不显式设就几乎等于没有上限。

手写版反过来:零依赖、一个函数读到底、单步调试直接;代价是状态藏在循环的位置里,
存档、恢复、流式这些都要自己写。

所以两版的定位是:**手写版是行为基准,LangGraph 版是同一份行为在框架上的表达。**

## 目录结构

```
mcp_server/     服务端与全部纯逻辑(可 import,因此可单测)
  auth.py          JWT 签发/校验/guard(),工具级 scope 表
  guardrails.py    三层 SQL 护栏
  db_tools.py      列表 / 结构 / 查询
  kb_tools.py      检索 / 取原文
  t2sql_core.py    自然语言转 SQL 的状态机(有界重写)
  t2sql_graph.py   同一个状态机的 LangGraph 版(并列实现,手写版保留)
  cost.py          用量账本(JSONL 只追加,金额允许为空)
  dispatch.py      名字 → 工具函数的派发器(HTTP 层的唯一入口)
  http_api.py      FastAPI 入口:路由由 TOOL_SCOPES 现生成
  concurrency.py   asyncio 并发层:限流 + 超时降级 + 阻塞调用挪出事件循环
  selfcheck.py     仓库卫生扫描口径(词表、范围、掩码)
  verify_kit.py    退出码台账与判定
  deliverable_kit.py  交付物核验 G1~G8
experiments/    可重跑的入口脚本(全部幂等)
  29_concurrency_bench.py          三种跑法的实测并发度对照
  30_http_smoke.py                 HTTP 入口冒烟(真进程、真端口)
  31_ledger_under_concurrency.py   并发下的账本对账(需要模型凭据)
  33_impl_compare.py               手写版 vs LangGraph 版的真模型逐题对照(不调模型,读 09 的产物)
tests/          单元测试:零网络、零模型调用
tools/check.py  守门链
data/           公开数据(示例库 .sql + 公版文本 + 出处清单)
reports/        全部实测产物落盘
docs/           设计说明
```

## 数据与许可

- 示例数据库:Chinook(MIT),随仓库分发 `.sql` 源文件,由脚本构建成数据库
- 文本库:40 篇公版中文文本,逐篇的标题、作者、原文地址、许可与 sha256 见 [NOTICE.md](NOTICE.md)
- 构建产物(数据库文件、全文索引)不入库,由 `experiments/01_build_db.py` 与
  `experiments/02_build_kb.py` 确定性重建

## 怎么证明它是对的

四层,从细到粗:

| 谁 | 管什么 |
|---|---|
| `python -m pytest` | 某个函数对不对 |
| [reports/verify.md](reports/verify.md) | 每个入口脚本的**实际退出码**对不对得上声明 |
| [reports/deliverable_check.md](reports/deliverable_check.md) | 文档里写的和仓库里**对不对得上** |
| [reports/final_selfcheck.md](reports/final_selfcheck.md) | 发布前 9 项红线,逐项报命中数 |

三条原则写在 `docs/design.md` 里,这里只列结论:

- **每一项检查都要能红。** 只证明「会绿」的检查,一个永远返回 `PASS` 的假实现也能通过。
- **「未完成」必须交证据。** 需要模型凭据的脚本以退出码 5 结束,同时留下写清卡在哪一步的
  「未完成」说明和 `BLOCKERS.md` 里对应的最小动作。三件缺一,判失败 ——
  没有落盘、没有解法的「未完成」,和「忘了跑」长得一模一样。
- **报告不能成为它自己报告的污染。** 自检报告要写出「在哪里命中了什么」,
  而写出来的那一刻,报告自己就带上了那个词。处置方式见 `docs/design.md` 第 4.4 节。

## 当前的完成状态

| 部分 | 状态 |
|---|---|
| 数据构建、鉴权、三层护栏、MCP 握手、知识库检索 | 已完成,有实测报告 |
| 自然语言转 SQL(`ask_database`) | **已跑通一次(2026-09-18)**:配好凭据后四条链路全部跑完并落盘,数字见 [RESULTS.md](RESULTS.md) 第二节。凭据不在仓库里,所以 clone 后要自配一份才能重跑(见 [BLOCKERS.md](BLOCKERS.md) 卡点 1) |
| 噪声带与配对统计 | 同上那次运行里跑完:尺子有正负控,数字见 [RESULTS.md](RESULTS.md) 第二节 |
| HTTP 入口(FastAPI) | 已完成:6 条工具路由 + `/tools` + `/healthz` + OpenAPI 文档;真实子进程冒烟 20 项全过,见 [reports/http_smoke.md](reports/http_smoke.md) |
| asyncio 并发层 | 已完成:限流、超时降级、阻塞调用挪出事件循环。**实测并发度**(不是设定值):串行 1 / 天真 gather 1 / 正确并发 6,见 [reports/concurrency.md](reports/concurrency.md) |
| 并发下的账本一致性 | 已完成(2026-09-22,凭据以环境变量注入):并发批与串行批各 12 题,账本新增行数都等于实际调用次数 12,见 [reports/ledger_under_concurrency.md](reports/ledger_under_concurrency.md) |

真实跑出来的数字见 [RESULTS.md](RESULTS.md);每个数字都注明了脚本与口径。

## 已知边界

- **卫生扫描用的词表按设计不在仓库里**:那张「不许出现的名字 / 数字 / 术语」表
  放在仓库**外面**(`MCP_TOOLKIT_WORDLIST` 指向的 `.py`,没设就找仓库同级目录的
  `mcp-guarded-toolkit-wordlist.py`)。把它放进仓库等于在仓库里再造一份那些字面量 ——
  那正是这个仓库修掉的那座「明文桥」。**代价写清楚**:clone 下来跑自检时,
  靠词表的那几项判 `SKIP`(**没查,不是通过**),所以 `python tools/check.py`
  在没有词表的机器上停在「未完成」,**不会全绿**。这不是失败,但也不是通过。
- **第一次跑之前要构建数据**:`data/` 下的示例库与全文索引是构建产物、不入库,
  所以 clone 下来直接跑闸门会停在「示例库还没构建」,先跑「快速开始」第 2 步
  那两条命令(不需要联网)。
- **不联网**:数据随仓库分发;只有「重新抓语料」这一个脚本需要网络,且它是可选的。
- **护栏不是形式化证明**:三层护栏(文本校验 / 只读连接 / 引擎级 authorizer)
  挡的是「写不进数据库」,不是「SQL 一定正确」。`experiments/11_guardrail_test.py`
  列了 41 条负例,那 41 条之外没测过的写法是未知的。
- **交付物核验 G4 的强度有限**:它查「文档里的 `--flag` 在源码里出现过」,
  不保证 `argparse` 真的注册了那个参数。
- **本机现在没有模型凭据**,所以那四条需要模型的命令不能重跑 —— 它们会以退出码 5 结束,
  并写出「未完成」声明。**已经跑通的那一次(2026-09-18)数字在 [RESULTS.md](RESULTS.md) 第二节**,
  是实测,不是 0,也不是任何估计值。(本机能拿到凭据与「工作区里没有 `.env`」这条发布自检
  互斥,所以两者不可能同时成立,见 [BLOCKERS.md](BLOCKERS.md) 卡点 1。)

设计取舍与代价逐条记在 [DECISIONS.md](DECISIONS.md),整体思路见 [docs/design.md](docs/design.md)。