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 提交并手跑等价检查。
This commit is contained in:
@@ -6,8 +6,15 @@ last_reviewed: 2026-10-19
|
||||
|
||||
| 文件/目录 | 说明 |
|
||||
|-----------|------|
|
||||
| `gen_mcp_docs.py` | 从运行时 schema 重新生成 `docs/MCP.md` 的工具参考一节(`make mcp-docs`;`make docs-check`
|
||||
只比对,漂移退出码 1)。加了工具却没在 GROUPS 登记会直接报错——刻意闸门 |
|
||||
| `install_skill.py` | 把报销技能同步到用户级技能目录,让它脱离本仓库可用(`make skill-install` /
|
||||
`make skill-check`)。装之前会核实该目录作为技能来源是否启用:装进停用的来源里,宿主永远扫不到 |
|
||||
| `mcp_smoke.py` | MCP stdio 自检:拉起自身、打印工具清单、验证 instructions 已下发、调一次 `case_list`。
|
||||
`make mcp-smoke` 或 `python tasks.py mcp-smoke` |
|
||||
| `data/` | 项目级配置目录(见 `data/README.md`) |
|
||||
|
||||
三个脚本都不改业务代码:`mcp-smoke` 验线上契约,`mcp-docs` 由线上 schema 出文档,`install_skill` 只管拷贝。
|
||||
改完工具的顺序是 `mcp-smoke` → `mcp-docs`。
|
||||
|
||||
改完 MCP 工具一定要跑 `mcp_smoke`:它验的是**线上传输**的契约,进程内 import 成功不代表宿主能看到工具。
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""从运行中的 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())
|
||||
@@ -36,7 +36,8 @@ def resolve_home() -> Path:
|
||||
try:
|
||||
return Path.home()
|
||||
except RuntimeError:
|
||||
raise SystemExit("无法确定用户主目录:设 DSH_SKILLS_DIR,或用 --dest 指定技能目录") from None
|
||||
print("无法确定用户主目录:设 DSH_SKILLS_DIR,或用 --dest 指定技能目录", file=sys.stderr)
|
||||
raise SystemExit(4) from None
|
||||
|
||||
|
||||
def norm(path_str: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user