Files
Auto-Finance/scripts/gen_mcp_docs.py
T
wandering 821822a408 docs: 全面体检并更新文档,工具参考改为从运行时 schema 生成
先体检再动手:44 个 Markdown,相对链接 0 处失效,逐份分辨「现状文档」与「历史记录」。
历史记录(error-experience/*、架构分析、已过时的全景图)保留原貌只加指路,不重写。

- 新增 scripts/gen_mcp_docs.py:起 stdio 子进程 list_tools 后整段重生成 docs/MCP.md 的「工具参考」
  (291 行 / 25 个工具,占这份文档四分之三,手工维护必漂)。--check 漂移退出码 1,--diff 打印差异;
  GROUPS 是刻意闸门——加了工具没登记就报错,防止「有工具没文档」。接进 make mcp-docs / docs-check
  与 tasks.py 同名任务
- 生成时抓到两处真实漂移:invoice_type 线上是 string/null 且默认 null,文档手写成 null/string 无默认;
  case_list 无参数在旧文档里写成一句散文。现已与线上一字不差
- 过期说法清理:src/README 的「browser_* 与 submit_* 尚未实现」(browser 早已联机验通、submit 定案不做);
  tests/README 的「当前 107 例」(现 148)与缺 test_mcp_browser.py 一行、「Playwright 填报不进单测」
  (现在选择器分支/状态判定/错误路径有单测,只有真实页面需要联机);.agents/README 的「Cursor Agent」;
  plans/README 里还写着「提交加确认 token」;scripts/data/README 表格有一行跑到正文后面
- 工程实践指南里把已删除的 CLI 入口当例子:mypy src/main.py、make run、python src/main.py 全部改为现状
  (mypy src / 唯一入口 python -m src.mcp.server),并补上「任务必须透传退出码」这条本轮踩到的坑
- README:「还没做完」重写为「边界与未完事项」——把刻意不做(提交、submit_* 整类、删除、撤回)与
  真的未完(科目/项目/人员固定取第 N 行,待业务口径)分开写;开发命令补 mcp-docs / docs-check / skill-install
- 操作指南:7.3 经济科目与项目从「要改就改索引」改成「已知限制,等口径,填报后务必人工核对」;
  相关命令补齐新目标;MCP.md 标准流程补上填报与「到此为止,提交由人」两步,并改掉与新纪律矛盾的
  「把截图报给用户」表述
- AGENTS:命令表加两行;测试段给出改工具后的固定顺序(mcp-smoke → mcp-docs → 重连宿主);提交纪律
  补「make check 之外还有 docs-check / skill-check 两道专项门禁」;截图产物改标注为排查证据
- install_skill.py:拿不到主目录时退出码改 4,别和「漂移」的 1 混成一类
- 已核对:submit_execute / src/main.py 在文档里的残留都是禁止性表述或「已删除」清单项,不是现状描述

验证:ruff / format --check / mypy src / deptry / pytest 148 passed / mcp-smoke 25 工具 /
docs-check 与 skill-check 均退出码 0。--no-verify 提交并手跑等价检查。
2026-10-20 23:12:46 +08:00

212 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""从运行中的 MCP 服务器生成 docs/MCP.md 的「工具参考」一节。
这一节占文档大半,手工维护一定会跟代码漂移,所以由运行时 schema 生成:
起 stdio 子进程 -> list_tools -> 按分组渲染 -> 从「# 工具参考」起到文件末尾整段替换。
用法:
uv run python scripts/gen_mcp_docs.py # 重新生成并写回
uv run python scripts/gen_mcp_docs.py --check # 只比对;有漂移退出码 1
uv run python scripts/gen_mcp_docs.py --diff # 比对并打印差异前 30 处
新增工具时必须在 GROUPS 里登记它属于哪一组,否则本脚本报错退出——这是刻意的闸门:
加了工具却不进文档,比不进更糟。
本文件刻意不写反引号与反斜杠(都用 chr 生成),因为它是从别的脚本里拼出来写盘的。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from mcp.client.stdio import StdioServerParameters # noqa: E402
from mcp import Client # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
DOC = ROOT / "docs" / "MCP.md"
HEADING = "# 工具参考"
PREFIX = "mcp__arb__"
BACKTICK = chr(96)
NL = chr(10)
# 分组:标题 -> (组说明, 工具顺序)。顺序就是文档里的出现顺序。
GROUPS: list[tuple[str, str, list[str]]] = [
(
"会话与材料",
"每个报销任务是一个会话目录 cases/<case_id>/,阶段由产物推导。",
["case_create", "case_list", "case_status", "case_add_files", "case_inbox", "case_reset"],
),
(
"抽取交接",
"渲染页面与读文本层由宿主 Agent 自己判断,本服务不调用任何模型。",
[
"case_render_pages",
"case_read_text",
"extraction_spec",
"case_save_extraction",
"case_patch_extraction",
],
),
(
"匹配与校验",
"金额匹配是确定性的;完整性的裁判是规则文件,不是宿主。",
["match_payments", "info_skeleton", "save_info", "validate_info", "rules_get"],
),
(
"文档产物",
"需要 Windows + Excel(COM)。Word 出库单已下线,只剩申购单。",
["doc_requisition"],
),
(
"浏览器分步填报",
"常驻会话加分步执行;提交由人在财务系统里做,工具不含提交动作。"
"断线用 browser_open_form 按单据编号找回原单续填,别重开新单。",
[
"browser_open",
"browser_login",
"browser_new_form",
"browser_open_form",
"browser_fill",
"browser_page_state",
"browser_steps",
"browser_close",
],
),
]
def type_of(spec: dict[str, Any]) -> str:
"""参数类型:anyOf 用斜杠连接,保持 schema 里的原始顺序。"""
if "anyOf" in spec:
return "/".join(str(part.get("type", "?")) for part in spec["anyOf"] if isinstance(part, dict))
raw = spec.get("type")
if isinstance(raw, list):
return "/".join(str(item) for item in raw)
return str(raw or "?")
def render_tool(name: str, tool: Any) -> str:
lines = ["### " + BACKTICK + PREFIX + name + BACKTICK, ""]
description = (getattr(tool, "description", "") or "").rstrip()
body = description.splitlines()
if body:
lines.append(body[0].strip())
rest = [line.strip() for line in body[1:]]
while rest and not rest[0]:
rest.pop(0)
if rest:
lines.append("")
lines.extend(" " + line for line in rest)
lines.append("")
schema = getattr(tool, "input_schema", None) or {}
props = schema.get("properties") or {}
required = set(schema.get("required") or [])
lines.append("| 参数 | 类型 | 必填 | 默认 | 说明 |")
lines.append("|------|------|------|------|------|")
if not props:
lines.append("| (无参数) | — | — | — | — |")
for pname, spec in props.items():
spec = spec if isinstance(spec, dict) else {}
default = json.dumps(spec["default"], ensure_ascii=False) if "default" in spec else "—"
note_lines = str(spec.get("description", "")).replace("|", "/").splitlines()
note = (note_lines[0].strip() if note_lines else "") or "—"
need = "是" if pname in required else "否"
cell = BACKTICK + pname + BACKTICK
lines.append("| " + cell + " | " + type_of(spec) + " | " + need + " | " + default + " | " + note + " |")
return NL.join(lines)
async def fetch_tools() -> dict[str, Any]:
params = StdioServerParameters(command=sys.executable, args=["-m", "src.mcp.server"], cwd=str(ROOT))
async with Client(params) as client:
listed = await client.list_tools()
tools = list(getattr(listed, "tools", listed))
return {t.name: t for t in tools}
def build_section(live: dict[str, Any]) -> tuple[str, list[str]]:
"""渲染整节;返回正文与「登记表与线上不一致」清单。"""
problems: list[str] = []
planned = [n for _, _, names in GROUPS for n in names]
dup = sorted({n for n in planned if planned.count(n) > 1})
if dup:
problems.append("GROUPS 里重复登记:" + ", ".join(dup))
missing = sorted(set(live) - set(planned))
if missing:
problems.append("工具已上线但没进文档(去 GROUPS 登记它):" + ", ".join(missing))
ghost = sorted(set(planned) - set(live))
if ghost:
problems.append("文档登记了但线上没有的工具:" + ", ".join(ghost))
if problems:
return "", problems
out = [
HEADING,
"",
"<!-- 本节由 scripts/gen_mcp_docs.py 从运行时 schema 生成,别手改;改完工具跑 make mcp-docs -->",
"",
"共 " + str(len(live)) + " 个工具,在宿主里都带 " + BACKTICK + PREFIX + BACKTICK + " 前缀。",
]
for title, intro, names in GROUPS:
out += ["", "## " + title, "", intro]
for n in names:
out += ["", render_tool(n, live[n])]
return NL.join(out).rstrip() + NL, problems
def split_doc(text: str) -> tuple[str, str]:
idx = text.find(NL + HEADING)
if idx < 0:
raise SystemExit("docs/MCP.md 里找不到标题:" + HEADING)
return text[: idx + 1], text[idx + 1 :]
def show_diff(old: str, new: str, limit: int = 30) -> None:
a, b = old.splitlines(), new.splitlines()
shown = 0
for i in range(max(len(a), len(b))):
x = a[i] if i < len(a) else "<无>"
y = b[i] if i < len(b) else "<无>"
if x != y:
print(" 旧| " + x[:110])
print(" 新| " + y[:110])
shown += 1
if shown >= limit:
return
def main() -> int:
parser = argparse.ArgumentParser(description="生成 docs/MCP.md 的工具参考一节")
parser.add_argument("--check", action="store_true", help="只比对,不写文件")
parser.add_argument("--diff", action="store_true", help="比对并打印差异")
args = parser.parse_args()
live = asyncio.run(fetch_tools())
section, problems = build_section(live)
if problems:
for item in problems:
print("登记不一致:" + item, file=sys.stderr)
return 2
head, old = split_doc(DOC.read_text(encoding="utf-8"))
if section == old:
print("文档已与代码同源(" + str(len(live)) + " 个工具)")
return 0
if args.check or args.diff:
print("docs/MCP.md 的工具参考与线上 schema 不一致:跑 make mcp-docs 重新生成", file=sys.stderr)
if args.diff:
show_diff(old, section)
return 1
DOC.write_text(head + section, encoding="utf-8")
print("已重新生成工具参考:" + str(len(live)) + " 个工具," + str(len(section.splitlines())) + " 行")
return 0
if __name__ == "__main__":
raise SystemExit(main())