这篇把三个能力组合成一个无人值守代码审查流程:
.claude/agents/code-reviewer.md定义一个只读的审查子代理;.mcp.json接入项目规范的 MCP 服务器(复用 54 篇 C 的notes_mcp.py);review.py用claude -p --output-format json --json-schema运行审查,拿到结构化的问题清单,退出码可以直接给 CI 用。另外写一个
agents_lint.py,按文档检查子代理定义里的字段、工具名、权限模式,以及引用的 MCP 服务器是否存在。
先看清成品(box 上真实运行)
code-reviewer.md检查通过。故意写错的doc-writer.md查出 3 个 ERROR(未知工具Browser、permissionMode: yolo、引用了不存在的slack)和 3 个 WARN。- MCP 服务器用官方 SDK 客户端真实调用通过(同 54 篇)。
review.py用我自己写的替身fake_claude.py测了 5 种返回:有问题、无问题、拒绝、出错、非 JSON 输出。- pytest 11 项通过。
合规提醒:Claude Code 需要 Claude 订阅或 Anthropic API Key,只能在 Anthropic 支持的国家和地区使用,不要用代理或共享账号绕过限制。代码和对话会发送给 Anthropic 处理,数据保留和训练政策见官方 Data usage 页面,公司代码先确认团队政策。
.claude/settings.json、.mcp.json、CLAUDE.md会提交进仓库,里面不要写密钥。本文不列价格,以官网为准。
运行说明:box 上没有安装 Claude Code CLI。官方安装脚本
curl -fsSL https://claude.ai/install.sh | bash在执行环境里被安全审查拦下,申请的批准也没有得到回复,所以我没有绕过它去安装。因此 Claude Code 实际是否加载这些文件、实际的行为,都没有在真实会话里验证。本文的检查规则全部依据官方文档(下面会标出处),脚本和测试都在 box 上真实运行过。
关于
fake_claude.py:这是我自己写的测试替身,不是 Anthropic 的程序。它只按 headless 文档里列出的字段(result、is_error、structured_output、total_cost_usd、session_id)输出 JSON。真实 CLI 的输出可能还有更多字段。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
.claude/agents/code-reviewer.md |
只读审查子代理:工具白名单、禁用 Edit 和 Write、可以用 MCP |
.claude/agents/doc-writer.md |
故意写错的反例 |
agents_lint.py |
子代理定义检查 |
.mcp.json、notes_mcp.py、notes/ |
项目规范 MCP 服务器 |
review.py |
headless 审查脚本。退出码:0 表示无问题,1 表示有问题,2 表示运行失败 |
fake_claude.py、test_c.py、test_mcp.py、demo_client.py |
测试 |
需要准备哪些环境?
Python 3.10 以上,pip install "mcp>=2" pyyaml pytest。真正运行审查需要安装 Claude Code 并完成登录,或者设置 ANTHROPIC_API_KEY。
要点(来自官方文档)
| 主题 | 内容 |
|---|---|
| 子代理位置 | .claude/agents/(项目级)和 ~/.claude/agents/(个人级),递归扫描 |
| 必填字段 | name(最多 256 个字符)和 description(决定什么时候委派给它) |
| 可选字段 | tools、disallowedTools、model、permissionMode、maxTurns、mcpServers、hooks、memory、color 等(完整列表见 agents_lint.py 里的 FIELDS) |
permissionMode |
default、acceptEdits、auto、dontAsk、bypassPermissions、plan、manual |
| 插件子代理 | 出于安全考虑,忽略 hooks、mcpServers、permissionMode |
.mcp.json |
项目级的 mcpServers;第一次使用需要在 claude 里批准,之前状态显示为 Pending approval |
| headless | -p、--output-format json、--json-schema;拒绝或失败时没有 structured_output 字段,使用前必须先检查 |
| 其他参数 | --allowedTools、--permission-mode、--max-turns、--mcp-config、--strict-mcp-config 都在 CLI reference 里 |
文件结构
57-claude-code-C-src/
├── .claude/agents/{code-reviewer.md, doc-writer.md}
├── .mcp.json
├── notes_mcp.py
├── notes/{deploy.md, api-style.md}
├── agents_lint.py
├── review.py
├── fake_claude.py
├── demo_client.py
├── test_mcp.py
└── test_c.py
第 1 步:子代理与检查
---
name: code-reviewer
description: 代码改完、提交之前使用。只读审查改动,对照 notes 里的接口规范给出问题清单,不修改文件。
tools: Read, Grep, Glob, Bash(git diff *)
disallowedTools: Edit, Write
mcpServers:
- project-notes
model: inherit
maxTurns: 15
color: blue
---
你是代码审查员。步骤:
1. 用 `git diff` 查看改动。
2. 用 project-notes 的 search_notes 查找相关规范(比如 api-style)。
3. 输出问题清单:文件:行号、问题、建议。没有问题就说“未发现问题”。
不要修改任何文件。
<!-- 反例 -->
---
name: Doc Writer
description: 写文档
tools: Read, Write, WebSearch, Browser
permissionMode: yolo
mcpServers: [slack]
temperature: 0.2
---
帮我写文档。
"""按官方 sub-agents 文档检查 .claude/agents/**/*.md,以及 mcpServers 引用是否在 .mcp.json 中。退出码:有 ERROR 为 1。"""
import json, pathlib, re, sys
import yaml
ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".")
FIELDS = {"name", "description", "tools", "disallowedTools", "model", "permissionMode", "maxTurns", "autoCompactWindow",
"skills", "mcpServers", "hooks", "memory", "background", "omitClaudeMd", "effort", "isolation", "color", "initialPrompt"}
MODES = {"default", "acceptEdits", "auto", "dontAsk", "bypassPermissions", "plan", "manual"}
COLORS = {"red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"}
# 内置工具名:取自 code.claude.com/docs/en/tools-reference 的工具表(2026-10 版本);mcp__ 开头的是 MCP 工具
TOOLS = {"Agent", "Artifact", "AskUserQuestion", "Bash", "CronCreate", "CronDelete", "CronList", "Edit",
"EndConversation", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "Glob", "Grep",
"LSP", "ListAgents", "ListMcpResourcesTool", "Monitor", "NotebookEdit", "PowerShell",
"PushNotification", "Read", "ReadMcpResourceTool", "RemoteTrigger", "ReportFindings",
"ScheduleWakeup", "SendFeedback", "SendMessage", "SendUserFile", "ShareOnboardingGuide", "Skill",
"SubagentHandback", "TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", "TaskUpdate",
"TodoWrite", "ToolSearch", "WaitForMcpServers", "WebFetch", "WebSearch", "Workflow", "Write"}
def as_list(v):
return [x.strip() for x in v.split(",")] if isinstance(v, str) else list(v or [])
def lint(path, mcp_names):
out = []
m = re.match(r"^---\n(.*?)\n---\n(.*)", path.read_text(encoding="utf-8"), re.S)
if not m:
return [("ERROR", "缺少 YAML frontmatter")]
fm, body = yaml.safe_load(m.group(1)) or {}, m.group(2)
for k in ("name", "description"):
if not fm.get(k):
out.append(("ERROR", f"缺少必填字段 {k}"))
if fm.get("name") and not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,255}", str(fm["name"])):
out.append(("WARN", f"name {fm['name']!r} 建议用小写字母、数字和连字符,如 code-reviewer"))
if len(str(fm.get("description", ""))) < 15:
out.append(("WARN", "description 太短:Claude 靠它决定何时委派,写清楚“什么时候用”"))
for k in fm:
if k not in FIELDS:
out.append(("WARN", f"未知字段 {k}(文档未列出)"))
for t in as_list(fm.get("tools")) + as_list(fm.get("disallowedTools")):
base = t.split("(", 1)[0]
if base not in TOOLS and not base.startswith("mcp__"):
out.append(("ERROR", f"未知工具 {t}"))
if "permissionMode" in fm:
if fm["permissionMode"] not in MODES:
out.append(("ERROR", f"permissionMode {fm['permissionMode']!r} 不是合法值"))
elif fm["permissionMode"] == "bypassPermissions":
out.append(("WARN", "bypassPermissions 会跳过所有权限检查,团队仓库里不要用"))
if fm.get("color") and fm["color"] not in COLORS:
out.append(("ERROR", f"color {fm['color']!r} 不是合法值"))
for s in fm.get("mcpServers") or []:
if isinstance(s, str) and s not in mcp_names:
out.append(("ERROR", f"mcpServers 引用的 {s} 不在 .mcp.json 中"))
if fm.get("tools") and "Write" in as_list(fm["tools"]) and "不要修改" in body:
out.append(("WARN", "正文说不修改文件,但 tools 里给了 Write"))
return out
def main():
mcp = ROOT / ".mcp.json"
names = set(json.loads(mcp.read_text())["mcpServers"]) if mcp.exists() else set()
bad = False
for p in sorted((ROOT / ".claude/agents").rglob("*.md")):
res = lint(p, names)
print(f"{p.relative_to(ROOT)}: {'OK' if not res else ''}")
for lvl, msg in res:
print(f" [{lvl}] {msg}"); bad |= lvl == "ERROR"
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
第 2 步:MCP
{
"mcpServers": {
"project-notes": {
"command": "python3",
"args": ["notes_mcp.py"],
"env": {"NOTES_DIR": "notes"}
}
}
}
notes_mcp.py 和 54 篇 C 完全一样,代码和说明见那一篇。.mcp.json 里的 python3 必须是装了 mcp 包的那个解释器,否则服务器启动不了(54 篇的调试示例遇到过这个问题)。
第 3 步:headless 审查
"""无人值守代码审查:claude -p + code-reviewer 子代理 + project-notes MCP + JSON Schema 结构化输出。
只用官方 CLI 参数(cli-reference);解析 headless 文档里列出的 json 字段:result / is_error / structured_output / total_cost_usd。
退出码:0 无问题;1 有问题;2 运行失败或没有结构化输出。"""
import json, os, subprocess, sys
CLAUDE = os.environ.get("CLAUDE_BIN", "claude")
SCHEMA = {"type": "object", "required": ["issues"], "properties": {"issues": {"type": "array", "items": {
"type": "object", "required": ["file", "line", "problem"],
"properties": {"file": {"type": "string"}, "line": {"type": "integer"}, "problem": {"type": "string"},
"suggestion": {"type": "string"}}}}}}
ALLOWED = "Read,Grep,Glob,Bash(git diff *),mcp__project-notes__search_notes,mcp__project-notes__read_note"
def build_argv(base="HEAD"):
prompt = f"用 code-reviewer 子代理审查 `git diff {base}` 的改动,按 schema 返回问题清单;没有问题返回空数组。"
return [CLAUDE, "-p", prompt, "--output-format", "json", "--json-schema", json.dumps(SCHEMA),
"--allowedTools", ALLOWED, "--permission-mode", "dontAsk", "--max-turns", "20",
"--mcp-config", ".mcp.json", "--strict-mcp-config"]
def run(base="HEAD", timeout=600):
try:
p = subprocess.run(build_argv(base), capture_output=True, text=True, timeout=timeout)
except FileNotFoundError:
return 2, f"找不到 {CLAUDE},请先安装 Claude Code", None
except subprocess.TimeoutExpired:
return 2, "超时", None
try:
out = json.loads(p.stdout)
except ValueError:
return 2, f"非 JSON 输出(exit {p.returncode}):{(p.stderr or p.stdout).strip()[:300]}", None
if p.returncode != 0 or out.get("is_error"):
return 2, f"运行失败:{str(out.get('result'))[:300]}", out
if "structured_output" not in out: # 文档:拒绝或失败时没有这个字段,必须先检查
return 2, "没有 structured_output(模型可能拒绝了请求)", out
issues = out["structured_output"]["issues"]
return (1 if issues else 0), issues, out
if __name__ == "__main__":
code, issues, raw = run(*sys.argv[1:2])
if code == 2:
print("✗", issues); sys.exit(2)
for i in issues:
print(f"{i['file']}:{i['line']} {i['problem']}" + (f" → {i['suggestion']}" if i.get("suggestion") else ""))
print(f"共 {len(issues)} 个问题;费用估算 {raw.get('total_cost_usd', 'n/a')}")
sys.exit(code)
#!/usr/bin/env python3
"""测试用替身(我自己写的,不是 Anthropic 的程序):模拟 claude -p --output-format json 的几种返回。
由环境变量 FAKE_MODE 控制:issues / clean / refused / error / garbage。字段名照 headless 文档。"""
import json, os, sys
mode = os.environ.get("FAKE_MODE", "clean")
open(os.environ.get("FAKE_ARGV_LOG", os.devnull), "w").write(json.dumps(sys.argv[1:]))
base = {"type": "result", "session_id": "fake-session", "total_cost_usd": 0.0, "is_error": False, "result": "done"}
if mode == "issues":
print(json.dumps({**base, "structured_output": {"issues": [
{"file": "src/api/orders.py", "line": 12, "problem": "错误响应没有用统一格式", "suggestion": "返回 {error: {code, message}}"}]}}))
elif mode == "clean":
print(json.dumps({**base, "structured_output": {"issues": []}}))
elif mode == "refused":
print(json.dumps({**base, "result": "I can't help with that."}))
elif mode == "error":
print(json.dumps({**base, "is_error": True, "result": "Invalid API key"})); sys.exit(1)
else:
print("Error: Authentication required"); sys.exit(1)
import json, os, subprocess, sys, pathlib
import pytest
import review, agents_lint
HERE = pathlib.Path(__file__).parent
@pytest.fixture
def fake(monkeypatch, tmp_path):
monkeypatch.setattr(review, "CLAUDE", str(HERE / "fake_claude.py"))
log = tmp_path / "argv.json"; monkeypatch.setenv("FAKE_ARGV_LOG", str(log))
return lambda mode: (monkeypatch.setenv("FAKE_MODE", mode), review.run(), json.loads(log.read_text()))[1:]
def test_issues(fake):
(code, issues, raw), argv = fake("issues")
assert code == 1 and issues[0]["line"] == 12
assert argv[argv.index("--output-format") + 1] == "json" and "--strict-mcp-config" in argv
assert json.loads(argv[argv.index("--json-schema") + 1])["required"] == ["issues"]
def test_clean(fake):
assert fake("clean")[0][:2] == (0, [])
@pytest.mark.parametrize("mode,msg", [("refused", "没有 structured_output"), ("error", "运行失败"), ("garbage", "非 JSON")])
def test_failures(fake, mode, msg):
code, why, _ = fake(mode)[0]
assert code == 2 and msg in why
def test_missing_cli(monkeypatch):
monkeypatch.setattr(review, "CLAUDE", "/nonexistent/claude")
assert review.run()[0] == 2
def test_agents_lint_repo():
r = subprocess.run([sys.executable, "agents_lint.py"], capture_output=True, text=True, cwd=HERE)
assert r.returncode == 1
assert "code-reviewer.md: OK" in r.stdout and "未知工具 Browser" in r.stdout and "'yolo'" in r.stdout
assert "slack 不在 .mcp.json" in r.stdout
def test_mcp_json_servers_exist():
cfg = json.loads((HERE / ".mcp.json").read_text())["mcpServers"]["project-notes"]
assert (HERE / cfg["args"][0]).exists() and (HERE / cfg["env"]["NOTES_DIR"]).is_dir()
第 4 步:运行
$ python agents_lint.py
.claude/agents/code-reviewer.md: OK
.claude/agents/doc-writer.md:
[WARN] name 'Doc Writer' 建议用小写字母、数字和连字符,如 code-reviewer
[WARN] description 太短:Claude 靠它决定何时委派,写清楚“什么时候用”
[WARN] 未知字段 temperature(文档未列出)
[ERROR] 未知工具 Browser
[ERROR] permissionMode 'yolo' 不是合法值
[ERROR] mcpServers 引用的 slack 不在 .mcp.json 中
$ CLAUDE_BIN=/nonexistent python review.py
✗ 找不到 /nonexistent,请先安装 Claude Code
$ CLAUDE_BIN=./fake_claude.py FAKE_MODE=issues python review.py
src/api/orders.py:12 错误响应没有用统一格式 → 返回 {error: {code, message}}
共 1 个问题;费用估算 0.0
$ echo $?
1
$ python -m pytest -q
11 passed in 2.39s
在装好并登录了 Claude Code 的机器上,去掉 CLAUDE_BIN 直接运行 python review.py 即可。
调试示例
工具白名单写错了两次。 第一版的 TOOLS 是我凭记忆写的,里面有 LS 和 MultiEdit,但当前的 tools-reference 文档里没有这两个工具,反而缺了 LSP、ToolSearch 等。于是我改成从 tools-reference.md 的工具表里自动提取。
结果第一次提取后,连 WebSearch 都被报成了未知工具。检查发现 TOOLS = {} 是空集合:我的正则用了 ^,但没有加 re.M,所以只会匹配文件开头。加上 re.M 后提取到了 46 个工具名,Browser 被正确报错,WebSearch 不再误报。
结论:白名单要从文档里提取,并注明文档的版本;提取完要检查数量(脚本里有 assert len(names) > 40)。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 子代理从来没有被调用 | description 太模糊 | 写清楚“什么时候用” |
| 子代理改了文件 | tools 没有限制 |
用 tools 白名单加 disallowedTools: Edit, Write |
| MCP 一直是 Pending approval | .mcp.json 还没批准 |
在项目里运行一次 claude 批准,或者 headless 时用 --mcp-config |
structured_output 报 KeyError |
模型拒绝了请求,或者运行失败 | 先检查字段是否存在,以及 is_error |
| CI 里出现权限提示卡住 | 没有设置权限模式 | 用 --allowedTools 配合 --permission-mode dontAsk,不要用 bypassPermissions |
模板规则
- 子代理的工具权限只给最小范围,审查类子代理只读。
- headless 调用一律用 JSON Schema,并检查
structured_output和is_error。 - 退出码要分清“有问题”和“运行失败”,CI 根据退出码处理。
项目资源
| 用途 | 地址 |
|---|---|
| Claude Code 文档索引(llms.txt) | https://code.claude.com/docs/llms.txt |
| Data usage | https://code.claude.com/docs/en/data-usage |
| Subagents | https://code.claude.com/docs/en/sub-agents |
| MCP | https://code.claude.com/docs/en/mcp |
| Headless 模式 | https://code.claude.com/docs/en/headless |
| CLI reference | https://code.claude.com/docs/en/cli-reference |
| Tools reference | https://code.claude.com/docs/en/tools-reference |
子代理、MCP 和 headless 组合起来,代码审查就能自动运行,结果是结构化的,也可以复现。