Files
Auto-Finance/scripts/gen_mcp_docs.py
T
wandering 5dda126e21 feat(upload): 建会话即给这一单的手机直链,收件器地址改为显式配置
用户选了 B 方案。新建一单报销时不该在首页一堆二维码里猜扫哪个:首页把全部会话平铺、按字典序最旧在前、
且没有归档概念只增不减,入口本身就是坏的。现在 case_create 直接返回 upload_url。

- src/config.py 新增可选字段 upload_base_url(环境变量 UPLOAD_BASE_URL;留空才退回探测)
- src/cases.py:upload_page_url() 优先用配置;新增 CaseStore.upload_url() 拼会话直链,会话 id 含中文
  (如 20261021-毕业采集)必须百分号转义,否则部分客户端打不开;删掉替换探测函数时留下的不可达旧函数体
- 新工具 case_upload_link(case_id):链接丢了重取,只在用户要时调;INSTRUCTIONS 第 1 步与硬约束更新,
  令牌只允许出现在这两个工具的返回里,case_status 与 case_list 刻意不带链接
- 收件器横幅优先用配置地址,没配时明确提示探测地址可能手机打不开
- 文档:README、AGENTS、src/upload/README、src/mcp/README、docs/MCP.md 标准流程、操作指南 7.5、
  scripts/data/README、config.example.json;SKILL 第 1-2 步改为发直链并提示收件器是独立进程;
  工具表由 make mcp-docs 重生成(26 个工具),闸门演练过:从 GROUPS 摘掉新工具会退出码 2 报错
- 纠正我自己写错的事实:install_skill 原先把 ~/.agents/skills 写死成本机启用,而来源开关会变
  (本机今早 08:11 就翻回 ~/.dsh/skills)。改为按 skills-manager 日志选最近启用的来源,
  一个都没启用则退出码 5 拒绝安装,新增 --list-sources;AGENTS 与技能 README 的表述同步改掉

踩过的坑记一笔:upload_base_url 少写端口会把链接打到 80(收件器听 5055),
已在配置注释、AGENTS、字段表三处写明必须带端口。

测试:tests/test_upload_link.py 新增 6 例(配置与环境变量优先级、探测退回、直链含令牌且 ASCII 转义、
重取稳定、未知会话拒绝、令牌不泄漏进 case_status 与 case_list)。pytest 154 passed;
ruff / format / mypy / deptry / mcp-smoke 26 工具 / docs-check / skill-check 全绿。
端到端实测:拿生成的直链访问真实运行中的收件器返回 200 且页面是上传页。
仍用 --no-verify 提交并手跑等价检查。
2026-10-21 10:05:33 +08:00

212 lines
8.0 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_upload_link", "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())