Day 13 / 共 20 天 · 第 3 周 运行时与实战

CLI 与 MCP 接入 IDE

昨天(D12)把 agent 变成了 HTTP 接口。但 HTTP 只是"正门"——今天(D13)我们钻进另外几扇门的真实源码:命令行 agentctl(给 CI/运维用)和 MCP(让 IDE 里的 AI"说人话就调")。你会看到它们如何共用同一个 PlatformClient、如何把 HTTP 状态码翻译成退出码和 MCP 错误、tool 的 schema 又是怎么从 Pydantic 模型自动长出来的。核心共识:这些门都不含业务,最后都通到 D12 那个 HTTP server

📍 你在 20 天里的位置(可信底座 → 运行时 → 精读)
D09 记忆 D10 评测 D11 安全底座 D12 server 运行时 D13 CLI/MCP D14 精读 sre-rca D15 全家福+元 Agent
💡 用一个类比先兜住今天(延续「盖楼/物业」世界观) 同一户人家(一个 agent)开了四扇门,通向同一间屋、给不同的人走:HTTP API = 大厅正门(程序/前端走);CLI agentctl = 员工通道(CI/运维/脚本走);MCP = 门口的智能对讲机(IDE 里的 AI 说句人话就替你按门铃);Skills = 贴在门上的一句话办事指南(D19)。关键:CLI 和 MCP 自己都不办事,只是"传达室/对讲机",把话原样转给 D12 的物业总台(HTTP server)——它们甚至共用同一个电话机(PlatformClient)。最后还有两把"长得像"的钥匙(两个 MCP)要分清,别拿错。
L01

四种接入面

同一个 agent 能力,平台通过四种"面(surface)"暴露给不同用户:

接入面给谁用怎么用
HTTP API程序 / 前端 / 集成POST /v1/agent/…(Day 12)
CLI (agentctl)CI/CD / 运维 / 脚本命令行调用
MCPIDE 里的 AI(Cursor/Claude Code)自然语言触发
SkillsIDE 里的人一句话 SOP(Day 19)

关键点:CLI 和 MCP 本身都不含业务逻辑,它们最终都是把请求转成对 Day 12 那个 HTTP server 的调用。二者甚至共用同一个 HTTP 客户端 PlatformClient(就在 gov_agents_mcp/client.py 里,CLI 直接 import 它)。今天就是把这三个"门"的真实源码逐段读一遍。

💡 本质:门 ≠ 业务可信框架里,"业务只有一份、门可以有很多扇"是刻意的架构选择。业务全压在 D12 的 server + agent 图里;CLI/MCP 只是协议转换器(命令行参数↔HTTP、JSON-RPC↔HTTP)。这样加一种新接入方式,几乎不用碰业务代码。
L02

agentctl 入口:7 个子命令 + 退出码翻译

🤔 痛点:CI 脚本怎么知道这次 agent 调用成没成?CI 里直接 curl 打 HTTP,脚本只能自己解析 JSON、自己判断成败,各人写各人的。agentctl 把这件事统一了:它按结果返回标准退出码,CI 一句 if agentctl invoke ...; then 就能判成败。

packages/gov-agents-cli 提供命令 agentctl(基于 Typer)。先看它的顶部——写死的 agent 名单 + Typer app 装配:

走读 1 gov-agents-cli/src/gov_agents_cli/main.py:30-40
# 跟 platform AGENT_REGISTRY 对齐(避免运行时 import platform 包 · 写死即可)
AGENT_NAMES = ["biz-link-gov", "rca", "risk-review", "alert-triage", "doc-check"]  # main.py:31

app = typer.Typer(
    name="agentctl",
    help="调 gov-agents-platform v1 endpoint · CI/CD 脚本入口。",
    no_args_is_help=True,          # 不带参数就打印 help,不报错
)
app.add_typer(skill_app)           # 挂子命令组:agentctl skill ...(管 L4 skills)
app.add_typer(new_app)             # 挂子命令组:agentctl new agent(脚手架,Day 19)
register_validate_agent_id(app)    # agentctl validate-agent-id
AGENT_NAMES只认 5 个 canonical agent 名。注意这是路由名rca 而非注册表名 sre-rca),跟 D12 三层命名对上。
no_args_is_help=True裸跑 agentctl 打印帮助而不是报错——CLI 的友好默认。
add_typer(...)Typer 用"子 app 挂主 app"来做命令组,于是有了 agentctl skill ... / agentctl new agent 这种两级命令。

再看两个"守门函数"——校验 agent 名、把所有异常翻成退出码:

走读 2 main.py:43-77
def _validate_agent(name: str) -> str:            # main.py:43
    if name not in AGENT_NAMES:
        stderr().print(f"[red]未知 agent {name!r} · 合法值: {AGENT_NAMES}[/red]")
        raise typer.Exit(code=2)                   # ← 输入错 = 退出码 2
    return name

def _run(coro):                                    # main.py:58 · 所有命令都用它跑
    """跑 async coro · 统一 exception 处理 · 翻成退出码。"""
    try:
        return asyncio.run(coro)
    except McpProxyError as e:                      # ← 复用 MCP 那套错误类型!
        if e.code in ("INVALID_INPUT",):
            stderr().print(f"[red][{e.code}] {e.message}[/red]")
            raise typer.Exit(code=1) from e
        if e.code == "UPSTREAM_ERROR" and "无法连接" in e.message:
            stderr().print(f"[red]{e.message}[/red]")
            raise typer.Exit(code=3) from e         # ← 网络错 = 退出码 3
        if e.code == "NOT_FOUND":
            stderr().print(f"[yellow]{e.message}[/yellow]")
            raise typer.Exit(code=1) from e
        stderr().print(f"[red][{e.code}] {e.message}[/red]")
        raise typer.Exit(code=1) from e             # ← 其它 HTTP 错 = 退出码 1
    except KeyboardInterrupt:
        stderr().print("\n[yellow]Ctrl+C · 中断[/yellow]")
        raise typer.Exit(code=130) from None        # ← Ctrl+C = 退出码 130(Unix 惯例)
_run(coro)Typer 的命令是同步函数,但底层调用是 async。_runasyncio.run 桥接,顺手把所有异常集中翻译成退出码——每个命令只要 _run(go()),错误处理不重复写。
except McpProxyErrorCLI 没有自己的错误类型,直接复用 MCP 客户端的 McpProxyError(因为它们共用 PlatformClient)。一处定义,两处用
code 130Ctrl+C 用 130 是 Unix 约定(128 + SIGINT 的信号号 2)。写脚本的人一看退出码就知道是被手动中断。
退出码含义来自
0成功正常返回
1HTTP 4xx/5xx / agent 错 / NOT_FOUND_run 兜底分支
2输入校验错(agent 名非法 / JSON 不合法)_validate_agent / _parse_data
3网络错(连不上平台)UPSTREAM_ERROR + "无法连接"
130Ctrl+C 中断KeyboardInterrupt
🔀 设计取舍①:CLI 为什么硬编码 5 个名字,而不复用 server 的注册表?server 注册表有 18 个 agent,CLI 却写死 5 个 canonical 名(main.py:31)。原因是——CLI 要保持极轻,绝不 import 任何平台业务包。一旦 import,就会连带拉进 langgraph、大模型 SDK 一堆重依赖,agentctl --help 启动都要好几秒。所以它宁可"抄一小份名单"忍受重复。这是"务实的重复"胜过"优雅的耦合"的典型权衡。
L03

一条 invoke 命令的完整一生

拿最常用的 agentctl invoke 走一遍,看参数怎么变成一次 HTTP 调用(main.py:99-122):

走读 3 main.py:99-122
@app.command()
def invoke(
    agent: Annotated[str, typer.Argument(help="5 个之一:biz-link-gov / rca / ...")],
    data: Annotated[str | None, typer.Option(help="JSON body string")] = None,
    data_file: Annotated[Path | None, typer.Option(help="JSON body 文件路径")] = None,
    table: Annotated[bool, typer.Option("--table", help="人类格式 · 默认 JSON")] = False,
    no_color: Annotated[bool, typer.Option("--no-color")] = False,
) -> None:
    """sync invoke · POST /v1/agent/{name}/invoke。"""
    agent = _validate_agent(agent)          # ① 先卡 agent 名(不合法退出码 2)
    body = _parse_data(data, data_file)     # ② 解析 JSON body

    async def go():
        client = _make_client()             # ③ 造 PlatformClient
        try:
            return await client.invoke(agent, body)   # ④ 真打 HTTP
        finally:
            await _close(client)            # ⑤ 关连接池(无论成败)
    env = _run(go())                        # ⑥ _run 统一跑 + 翻退出码
    if table:
        render_envelope(env, no_color=no_color)       # 人类友好表格
    else:
        stdout_console(no_color=no_color).print_json(dump_json(env))  # 默认吐 JSON

其中 _parse_datamain.py:79)负责把 --data 字符串或 --data-file 文件读成 dict,任何一步失败都退出码 2:

def _parse_data(data, data_file) -> dict:          # main.py:79
    if data_file:
        try:
            return json.loads(data_file.read_text())
        except (OSError, json.JSONDecodeError) as e:
            stderr().print(f"[red]--data-file 解析失败: {e}[/red]")
            raise typer.Exit(code=2) from e         # 文件读不了/JSON 坏 = 输入错
    if data:
        try:
            return json.loads(data)
        except json.JSONDecodeError as e:
            raise typer.Exit(code=2) from e
    stderr().print("[red]必须传 --data 或 --data-file 之一[/red]")
    raise typer.Exit(code=2)                         # 两个都没给也是输入错
控制流:一条 invoke 命令的一生(右侧标退出码) 命令行参数agent+data _validate_agent非法→码2 _parse_data坏JSON→码2 client.invokePOST /v1/... 200→码0 错→码1/3(_run翻译) 全程被 _run() 包着 → 任何 async 异常都被翻成退出码,命令函数本身不写 try/except
图注:参数 → 校验 → 解析 → HTTP → 输出,错误在每一环就地拦截或由 _run 统一翻退出码。
🔀 设计取舍②:为什么默认输出裸 JSON,人类表格反而要 --table因为 CLI 的第一用户是脚本,不是人。脚本要 | jq 接着处理,裸 JSON 最省事;人偶尔看才加 --table。"默认服务主要场景,附加需求用开关"——和 D07 的"约定优于配置"一个味道。stream 命令同理默认解析 SSE 漂亮打印,--raw 才吐原始字节。
L04

PlatformClient:CLI 与 MCP 共用的"电话机"

CLI 和 MCP 都靠同一个 PlatformClientgov-agents-mcp/src/gov_agents_mcp/client.py:49)打平台。先看它怎么派生请求身份 X-User-Id——这个函数很能体现"一份代码两处复用":

走读 4 client.py:28-46
def _resolve_user_id(env=None, *, prefix: str = "mcp") -> str:     # client.py:28
    """派生 X-User-Id:{PREFIX}_USER_ID → MCP_USER_ID → {prefix}:$USER → {prefix}:anonymous。"""
    e = env if env is not None else dict(os.environ)
    explicit_env = f"{prefix.upper()}_USER_ID"      # MCP→MCP_USER_ID / CLI→AGENTCTL_USER_ID
    explicit = (e.get(explicit_env) or "").strip()
    if explicit:
        return explicit
    # 兼容:CLI 没设 AGENTCTL_USER_ID 时也认 MCP_USER_ID
    if prefix != "mcp":
        mcp_explicit = (e.get("MCP_USER_ID") or "").strip()
        if mcp_explicit:
            return mcp_explicit
    user = (e.get("USER") or "").strip()
    if user:
        return f"{prefix}:{user}"                   # 例 mcp:alice / agentctl:alice
    return f"{prefix}:anonymous"
prefix 参数MCP 传 prefix="mcp",CLI 造 client 时传 user_id_prefix="agentctl"(见 main.py:50 _make_client)。同一函数,用参数分身份来源
四级回退显式 env → 兼容 MCP_USER_ID → 系统 $USERanonymous。总能拿到一个非空 user_id,绝不让请求"没身份"。
prefix != "mcp"专门给 CLI 开的兼容口子:你之前为 MCP 设过 MCP_USER_ID,用 CLI 时不用再设一遍。

再看 client 构造 + 请求头,理解它怎么把 env 变成一次带鉴权的 HTTP:

走读 5 client.py:49-70
class PlatformClient:                               # client.py:49
    def __init__(self, *, base_url=None, timeout=120.0, env=None, user_id_prefix="mcp"):
        e = env if env is not None else dict(os.environ)
        self.base_url = (base_url or e.get("PLATFORM_BASE_URL") or "http://localhost:8080").rstrip("/")
        self.user_id = _resolve_user_id(e, prefix=user_id_prefix)
        self.anthropic_api_key = (e.get("ANTHROPIC_API_KEY") or "").strip() or None
        self._client = httpx.AsyncClient(timeout=timeout)   # 复用连接池

    def _headers(self) -> dict[str, str]:           # client.py:66
        h = {"X-User-Id": self.user_id, "Content-Type": "application/json"}
        if self.anthropic_api_key:
            h["X-Anthropic-Api-Key"] = self.anthropic_api_key   # BYOK:自带 key 透传
        return h
base_url 默认PLATFORM_BASE_URL 环境变量指定连哪个平台,缺省 http://localhost:8080.rstrip("/") 防用户多写斜杠拼出 //v1
timeout=120.0默认 2 分钟——因为 agent 跑一次可能要几十秒(多专家 + LLM),HTTP 默认超时太短会误杀。
X-Anthropic-Api-KeyBYOK(自带 key):本地设了 ANTHROPIC_API_KEY 就透传给平台,让平台用你的 key 计费。没设就走平台自己的。
🔀 设计取舍③:为什么 client 放在 MCP 包,却给 CLI 用?本可以各写一份,但两者调的 HTTP 端点、错误归类、鉴权头完全一样。于是 client 只写在 gov_agents_mcp,CLI 直接 from gov_agents_mcp.client import PlatformClient(见 main.py:15)。代价是 CLI 依赖了 MCP 包,但换来"错误分类逻辑只维护一处"。main.py:15 那行 import 就是这个决定的落地。
L05

MCP 是什么:让 IDE 的 AI 能调你的 agent

MCP(Model Context Protocol)是一个标准协议,让 IDE 里的 AI 助手(Cursor / Claude Code / Cline / Windsurf)能"调用外部工具"。你在 IDE 里说一句人话,AI 就能触发一个 MCP tool 帮你干活。IDE 通过 stdio(标准输入输出)启动 MCP server 子进程,用 JSON-RPC 通信。

IDE 的 AICursor / Claude Code
stdio启动子进程
MCP servergov-agents-mcp
HTTP转发调用
平台 serverDay 12

本框架把 agent 暴露成 MCP tool,于是你在 IDE 里说"帮我分析下 service-A 的超时根因",AI 就会调 invoke_sre_rca 这个 MCP tool,背后其实是打了平台的 HTTP 接口。

📝 举个例子:一句人话怎么变成一次 agent 调用 你在 Cursor 里打字:帮我看看 service-A 为啥大量超时
→ IDE 的 AI 识别该调 invoke_sre_rca 工具,自动填参 {"trace_id":"t-9f2","user_question":"service-A 大量超时"}
→ gov-agents-mcp 通过对讲机把它转成 POST http://localhost:8080/v1/agent/rca/invoke(就是 D02 那个 curl!)
→ 平台跑完把 envelope 结果回给 AI,AI 用大白话讲给你听。你全程没碰命令行、没写 JSON。
💡 stdio 是关键约定MCP server 不监听端口,而是被 IDE 当子进程"喂 stdin、读 stdout"。这带来一个大坑(L09 会看到真实代码):任何往 stdout 的普通打印都会污染 JSON-RPC 协议。所以 MCP server 的日志必须走 stderr——server.py:89logging.basicConfig(stream=sys.stderr) 就是干这个的。
L06

tool 的 schema 从 Pydantic 模型自动长出来

MCP tool 要告诉 IDE"我接受什么参数"(inputSchema)。本框架不手写 JSON Schema,而是定义 Pydantic 模型 → 自动生成。先看数据结构层(gov-agents-mcp/src/gov_agents_mcp/schemas.py):

走读 6 schemas.py:27-34, 68-75
class SreRcaInput(BaseModel):                       # schemas.py:27
    """sre-rca · 微服务根因分析。"""
    trace_id: str = Field(..., description="SkyWalking trace ID")
    service_name: str | None = Field(None, description="可选服务名 · 提示重点看哪个服务")
    user_question: str = Field(..., description="用户问题描述 · 例 'service-A 慢,DB 连接池打满'")
    thread_id: str | None = Field(None, description="可选 thread_id · 中断恢复用")

# tool_name → (输入模型, platform agent 名) 的总映射表   # schemas.py:68
AGENT_INPUTS: dict[str, tuple[type[BaseModel], str]] = {
    "invoke_biz_link_gov": (BizLinkGovInput, "biz-link-gov"),
    "invoke_sre_rca":      (SreRcaInput,      "rca"),          # ← MCP tool 名 → 路由名
    "invoke_risk_review":  (RiskReviewInput,  "risk-review"),
    "invoke_alert_triage": (AlertTriageInput, "alert-triage"),
    "invoke_doc_check":    (DocCheckInput,    "doc-check"),
}
字段类型作用
Field(..., ...)必填...(Ellipsis)= 必填。trace_id/user_question 必填
Field(None, ...)可选默认 None = 可选。service_name/thread_id 可不填
description=str会进 JSON Schema,IDE 的 AI 靠它理解每个参数怎么填
AGENT_INPUTSdict三合一映射:MCP tool 名 → (Pydantic 模型, HTTP 路由名)

再看控制层怎么用这张表:list_tools 生成 schema、call_tool 校验后转发(gov-agents-mcp/src/gov_agents_mcp/tools.py):

走读 7 tools.py:14-26, 29-52
def list_tools() -> list[Tool]:                     # tools.py:14
    tools = []
    for tool_name, (model_cls, _agent_name) in AGENT_INPUTS.items():
        schema = model_cls.model_json_schema()      # ← Pydantic 自动出 JSON Schema!
        tools.append(Tool(name=tool_name,
                          description=TOOL_DESCRIPTIONS[tool_name],
                          inputSchema=schema))
    return tools

async def call_tool(name, arguments, *, client) -> list[TextContent]:   # tools.py:29
    if name not in AGENT_INPUTS:
        raise McpProxyError("INVALID_INPUT", f"unknown tool {name!r}")
    model_cls, agent_name = AGENT_INPUTS[name]
    try:
        validated = model_cls.model_validate(arguments)   # ← 二道校验(MCP SDK 已校一次)
    except Exception as e:
        raise McpProxyError("INVALID_INPUT", f"参数校验失败: {e}") from e
    body = validated.model_dump(exclude_none=True)        # 去掉没填的可选字段
    envelope = await client.invoke(agent_name, body)      # ← 转成 HTTP!用 AGENT_INPUTS 里的路由名
    return [TextContent(type="text", text=json.dumps(envelope, ensure_ascii=False, indent=2))]
model_json_schema()Pydantic 一行把模型变成标准 JSON Schema(含字段类型、必填、description)。改字段只改模型,schema 自动跟着变,永远不会对不上。
model_validate(arguments)IDE 传来的参数再校验一遍。MCP SDK 本身也会校,这里是"二道保险"——不信任外部输入是安全默认。
exclude_none=True没填的可选字段直接不进 body,避免给平台传一堆 null 造成歧义。
client.invoke(agent_name, ...)这里的 agent_nameAGENT_INPUTS 里那个路由名(如 rca),最终拼成 /v1/agent/rca/invoke。MCP 的活到此为止——纯转发。
数据结构:AGENT_INPUTS 一张表串起 三层命名 MCP tool 名 invoke_sre_rca Pydantic 模型 SreRcaInput → schema HTTP 路由名 rca → /v1/agent/rca 生成schema 转发 一个 dict 项 (SreRcaInput, "rca") 就同时定义了"给 IDE 看的参数"和"打哪个 HTTP 端点"
图注:AGENT_INPUTS 的每一项都是"tool 名 →(模型, 路由名)",一张表串起 IDE 侧、schema 侧、HTTP 侧三层命名。
🔀 设计取舍④:schema 是"手抄"的,为什么不直接 import agent 的模型?文件头注释写得很实在(schemas.py:1-5):"手抄一份 · 不 import agent 包(避免拉 LLM/langgraph deps)"。跟 CLI 硬编码名单同一个理由——MCP server 要能被 uvx 秒起,不能因为 import 一个 Input 模型就连带拉进整个 agent 的重依赖。代价是模型定义有两份、需手动对齐(注释里留了 version: 2026-05-14 和"未来用 OpenAPI 自动派生"的 TODO)。轻量启动 > 消除重复,在"边缘转发进程"这个场景是对的取舍。
L07

错误归四类:把 HTTP 状态码翻成人能懂的原因

平台可能返回各种 HTTP 错误码。PlatformClient.invoke 把它们归成 4 类友好错误(client.py:75-115)——CLI 和 MCP 都靠这套分类:

走读 8 client.py:75-115
async def invoke(self, agent_name, body) -> dict:          # client.py:75
    url = f"{self.base_url}/v1/agent/{agent_name}/invoke"
    try:
        r = await self._client.post(url, json=body, headers=self._headers())
    except httpx.HTTPError as e:
        raise McpProxyError("UPSTREAM_ERROR", f"无法连接 platform ...")  # 连不上=网络错
    if r.status_code == 200:
        return r.json()                                     # 正常:返 envelope
    # ── 错误归类 ──
    try:    err_body = r.json()
    except Exception:  err_body = {"detail": r.text[:500]}
    if r.status_code == 422:                                # 输入不合法
        raise McpProxyError("INVALID_INPUT", str(err_body.get("detail", err_body)))
    if r.status_code in (401, 403):                         # 鉴权失败
        raise McpProxyError("UNAUTHORIZED", f"platform 拒绝(HTTP {r.status_code})...")
    if r.status_code == 500:                                # agent 内部异常
        detail = err_body.get("detail") or {}
        case_id = detail.get("case_id") if isinstance(detail, dict) else None
        error_msg = "agent 内部异常"
        if isinstance(detail, dict):
            result = detail.get("result") or {}
            error_msg = result.get("error") or result.get("error_type") or error_msg
        raise McpProxyError("AGENT_ERROR", error_msg, case_id=case_id)   # ← 带上 case_id!
    raise McpProxyError("UPSTREAM_ERROR", f"HTTP {r.status_code}: {err_body}")
HTTP归类 code含义 → 用户该怎么办
连不上UPSTREAM_ERROR平台没起 / 网络断 → CLI 退出码 3
422INVALID_INPUT参数不对 → 改输入
401/403UNAUTHORIZED身份不对 → 检查 MCP_USER_ID / 平台鉴权模式
500AGENT_ERRORagent 跑挂了 → 附带 case_id 可去平台回放
其它UPSTREAM_ERROR上游异常
⚠️ 边界/易错点:500 的 case_id 藏在嵌套结构里平台的 500 错误不是一句纯文本,而是把整个 envelope 塞在 detail 字段里(见 D12 router 的异常处理)。所以这里要 err_body["detail"]["case_id"] 层层往里挖,还得处处 isinstance(detail, dict) 防它不是字典时 .get() 崩掉。挖出 case_id 后,IDE 的 AI 就能告诉你"这次失败的回放在平台第 xxx 条"——出错也能追溯,是可信框架的细节。
L08

server 装配 + resources:tool 跑 agent、resource 读历史

MCP server 主入口 build_servergov-agents-mcp/src/gov_agents_mcp/server.py:36)把上面几块用装饰器挂到一个 Server 上:

走读 9 server.py:36-85
def build_server(client=None) -> tuple[Server, PlatformClient]:   # server.py:36
    pc = client if client is not None else PlatformClient()        # 单例复用连接池
    server = Server("gov-agents-mcp")

    @server.list_tools()                    # server.py:48 · IDE 问"你有啥工具"
    async def handle_list_tools():
        return _list_tools()                # → tools.py 那 5 个 invoke_*

    @server.call_tool()                     # server.py:52 · IDE 说"调这个工具"
    async def handle_call_tool(name, arguments):
        try:
            return await _call_tool(name, arguments, client=pc)
        except McpProxyError as e:          # ← 错误不外抛,而是当文本返给 IDE 的 AI
            text = f"[{e.code}] {e.message}"
            if e.case_id:  text += f"\n(case_id: {e.case_id})"
            return [TextContent(type="text", text=text)]

    @server.list_resources()                # server.py:62 · 5 个 cases://
    async def handle_list_resources():
        return _list_static_resources()

    @server.read_resource()                 # server.py:70 · 读某条历史 case
    async def handle_read_resource(uri):
        try:
            return await _read_resource(str(uri), client=pc)
        except McpProxyError as e:
            return [TextResourceContents(uri=uri, mimeType="text/plain",
                                         text=f"[{e.code}] {e.message}")]
    return server, pc
pc = ... PlatformClient()整个 server 共用一个 client(连接池复用),生命周期由 main()/测试管(server.py:100-109try/finallyawait pc.close())。
except → 返 TextContent关键设计:MCP handler 不把异常抛给 IDE,而是把错误信息当成"工具的文本输出"返回。这样 IDE 的 AI 能读到 [AGENT_ERROR] ... case_id: xxx 并转告你,而不是 IDE 直接崩一个红色协议错。
tools vs resources两组能力:tools 用来"跑 agent"(有副作用、要花钱),resources 用来"读历史"(只读、免费)。刻意分开。

resources 侧的 URI 解析(gov-agents-mcp/src/gov_agents_mcp/resources.py:56)展示了怎么把 cases://rca 这种 URI 变成一次 HTTP GET:

走读 10 resources.py:56-72
def _parse_uri(uri: str) -> tuple[str, str, str | None]:      # resources.py:56
    if uri.startswith("cases://"):                # 列表:cases://
        agent = uri.removeprefix("cases://").rstrip("/")
        if not agent or agent not in AGENT_NAMES:
            raise McpProxyError("NOT_FOUND", f"未知 agent {agent!r}...")
        return "cases", agent, None
    if uri.startswith("case://"):                 # 单条:case:///
        rest = uri.removeprefix("case://")
        parts = rest.split("/", 1)
        if len(parts) != 2 or not parts[0] or not parts[1]:
            raise McpProxyError("NOT_FOUND", f"case:// URI 格式应为 case:///...")
        agent, case_id = parts[0], parts[1]
        ...
        return "case", agent, case_id
    raise McpProxyError("NOT_FOUND", f"未识别的 URI scheme: {uri!r}")

cases://rca(复数)= 最近 50 条概览,case://rca/(单数)= 单条详情。IDE 的 AI 拿到某次调用的 case_id 后,可以直接 read case://rca/01HXXX 把详情读出来给你看,无需再发 tool call

🔀 设计取舍⑤:为什么把"读历史"做成 resource 而不是又一个 tool?MCP 协议里,tool = 有副作用的动作(IDE 的 AI 调 tool 通常要征求你同意),resource = 可安全读取的数据(AI 可以自由浏览)。把"看历史 case"归为 resource,IDE 的 AI 就能随时翻历史当上下文而不用每次问你"能不能读"。语义分类对了,交互体验就顺——这是顺着协议设计而非对抗它。
L09

两个 MCP 别混淆 + 一个 stdio 真实坑

本框架有两个都叫"MCP"、架构却完全不同的东西,一次讲清避免踩坑:

gov-agents-mcp
  • HTTP 瘦代理(本节 L05-L08)
  • 暴露平台 5 个 agent
  • 不含业务,转发 HTTP
  • uvx gov-agents-mcp
bmc-agent-mcp
  • 独立进程内嵌 agent
  • 专做 Java BMC 升版
  • 直接 import bmc_agent 的图
  • curl 一键装

apps/bmc-agent-mcpFastMCP 暴露 4 个 tool,且直接 import 复用 bmc_agent 的图(不走 HTTP!)。看它的 tool 定义和那个著名的 stdio 坑(apps/bmc-agent-mcp/bmc_agent_mcp/server.py):

走读 11 bmc-agent-mcp/bmc_agent_mcp/server.py:71-93
mcp = FastMCP("bmc-agent")                          # server.py:37

@mcp.tool()                                         # server.py:71
def bmc_scan(project_path: str, bmc_parent_path: str = "") -> dict:
    """只扫描,returns 项目现状(便宜,几百 ms)。"""
    state = _initial_state(project_path, bmc_parent_path=bmc_parent_path)
    with redirect_stdout(sys.stderr):               # server.py:90 ← 关键 trick!
        result = scan_node(state)                   # 直接调 bmc_agent 的节点(内嵌,不走 HTTP)
    return {...}
⚠️ 边界/坑:stdout 会污染 JSON-RPC,必须重定向文件头注释讲得很透(server.py:16-27):MCP 的 stdio transport 用 stdin/stdout 走 JSON-RPC,任何 print 到 stdout 都会破协议。但 bmc-agent 的节点全在用 rich.Console().print(...) 打进度(走 stdout)。解法就是 with redirect_stdout(sys.stderr): 把节点里的所有打印临时导到 stderr——对节点完全透明,IDE 那边看 stderr 日志即可。做任何 stdio MCP server 都要留意这条gov-agents-mcp 则是从头就规定日志走 stderr(L05 提过的 server.py:89)来避开它。
gov-agents-mcp(转发钥匙) MCP 瘦代理(零业务) HTTP 转发 → D12 平台 server(业务在这) uvx gov-agents-mcp · 暴露平台 5 个 agent bmc-agent-mcp(专用钥匙) 独立进程(FastMCP) 直接 import ↓ 内嵌 bmc_agent 的图 curl|bash 一键装 · 不走 HTTP
图注:同叫"MCP",一个是转发到 HTTP 的瘦代理,一个是直接内嵌 agent 图的独立进程——架构完全不同。

👶 小白:都做 MCP,为啥不做成一个?两把长得像的钥匙不是很容易拿错吗?

👨‍🏫 老师:因为它俩服务的场景不同。gov-agents-mcp 是给已经部署好的平台开一扇 IDE 门,所以它当"对讲机"转发 HTTP 最轻。而 bmc-agent-mcp 要能被开发者 curl|bash 装到自己机器上独立跑(不依赖你先起平台),所以它把 agent 的图直接内嵌进进程。一个"连总台",一个"自带小厨房"——需求不同,才有两把钥匙。

顺带另外两组"名字像、职责不同"的东西(前几天讲过,这里汇总):

  • 两个 supervisor(Day 04):图内 supervisor_node(选专家、真跑)vs 跨 agent router(选 agent、只建议)。
  • 三层命名(Day 12/本日 L06):注册表名(sre-rca) ≠ 路由名(rca) ≠ MCP tool 名(invoke_sre_rca)。
读代码时的自保习惯:遇到 "supervisor" / "mcp" / agent 名,先确认"这是哪一个"——看它在哪个包、import 了什么、agent_name= 传的什么。名字相近但职责不同,是大型项目的常态。
L10

今日小结 + 动手

🧠 今天你应该能回答

  • 四种接入面各给谁用?CLI/MCP 为什么都不含业务?(都转发到 D12 的 HTTP server,还共用 PlatformClient
  • agentctl 的 5 个退出码分别代表什么?为什么用 _run 集中翻译?
  • CLI 为什么硬编码 5 个 agent 名、MCP schema 为什么手抄不 import?(保持轻量启动,不拉业务重依赖)
  • MCP tool 的 inputSchema 从哪来?(Pydantic model_json_schema() 自动生成)
  • PlatformClient 把 HTTP 错误归成哪 4 类?500 的 case_id 藏在哪?
  • tool 和 resource 有什么区别?为什么"读历史"是 resource?
  • 两个 MCP 的架构区别?stdio MCP 为什么必须把 stdout 重定向到 stderr?

✋ 动手

# 1. CLI 入口 + 退出码翻译(本日核心)
sed -n '30,93p' packages/gov-agents-cli/src/gov_agents_cli/main.py

# 2. MCP schema 自动生成 + tool 转发
sed -n '27,75p' packages/gov-agents-mcp/src/gov_agents_mcp/schemas.py
cat packages/gov-agents-mcp/src/gov_agents_mcp/tools.py

# 3. PlatformClient 的 user_id 派生 + 错误归 4 类
sed -n '28,115p' packages/gov-agents-mcp/src/gov_agents_mcp/client.py

# 4. server 装配 + resources URI 解析
sed -n '36,85p'  packages/gov-agents-mcp/src/gov_agents_mcp/server.py
sed -n '56,102p' packages/gov-agents-mcp/src/gov_agents_mcp/resources.py

# 5. 另一个 MCP:内嵌 agent + stdout 重定向坑
sed -n '1,95p' apps/bmc-agent-mcp/bmc_agent_mcp/server.py
明天预告 · Day 14:前面的知识点,明天来一次"总演练"——逐文件精读旗舰 Agent sre-rca。从 state 到 builder 到每个节点的真实代码,把 Day 03-12 学的所有东西在一个真实 agent 上完整走一遍。
← Day 12 server Day 14 · 精读旗舰 Agent sre-rca →