让专长 Agent 通过 MCP 读取工单并调用受控工具:资源、权限边界与失败恢复
协调 Agent 把一张工单的分析任务交给专长 Agent。本页实现这次协作里 MCP 的一半:一个把数据暴露为资源、把动作收进窄授权工具的工单服务器,以及专长 Agent 内嵌的调用方。A2A 的另一半——协调 Agent 如何发现专长 Agent、跟踪任务状态、收回结果——由 a2acn.com 的 A2A 指南负责,本页只在边界处说明分工。
下文服务器与客户端代码已对照协议修订版 2026-07-28,并于 2026-10-28 使用 Python SDK mcp==2.3.0 通过 stdio 端到端实跑。远程 HTTP 授权部分依据所列规范;本次没有实跑受控的 401/403 与 token audience 正反例。
做完你会得到什么
| 能力 | 暴露方式 | 谁能调用 |
|---|---|---|
| 读一张工单(主题、严重度、客户描述) | 资源 tickets://{ticket_id} | 能连到服务器的任何调用方 |
| 追加一条内部备注 | 工具 add_internal_note | 远程部署下 token 需带 tickets:notes;本地 stdio 下是进程属主 |
| 改派或关闭工单 | 不暴露。 高风险动作留在工单系统界面。 | — |
最后一行是本页的核心:专长 Agent 拿到的访问权限刚好够完成工作,不多一分。
先分类:哪些进资源,哪些进工具
写代码前先给每个能力分类。要问的不是"技术上能不能",而是读者会怎么用:
| 读者问题 | 原语 | 理由 |
|---|---|---|
| “481 号工单说了什么?” | 资源 | 读结构化数据;客户端按需读取,订阅后还能在变更时收到推送 |
| “把这个结论记到工单上” | 工具 | 有副作用,模型不能随手触发;需要 schema、描述和一次权限决策 |
| “下一步该做什么?” | 都不是——这是 Agent 自己的推理 | 不是服务器的职责 |
把这张表当门禁用:说不出读者问题与权限决策的能力,就不该出现在 MCP 服务器上。
服务器:一个资源、一个受控工具
按官方教程同样的方式安装:
uv add "mcp==2.3.0"ticket_server.py——资源把可预期的失败抛成 ResourceError,工具在任何副作用之前先校验输入:
import json
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceError
server = MCPServer("ticket-bridge")
# 代替你的工单系统 API。除了 add_internal_note,
# 服务器不会改动 TICKETS。
TICKETS = {
"481": {"subject": "Checkout fails on Safari", "severity": 2,
"report": "Customer reports the pay button does nothing on Safari 17.",
"notes": []},
}
@server.resource("tickets://{ticket_id}", name="ticket",
description="One support ticket: subject, severity, report, notes")
def ticket(ticket_id: str) -> str:
record = TICKETS.get(ticket_id)
if record is None:
# 可预期的失败:客户端原样看到这条消息。
raise ResourceError(f"no ticket {ticket_id}")
return json.dumps(record, ensure_ascii=False)
@server.tool()
def add_internal_note(ticket_id: str, note: str) -> str:
"""Append an internal note to a ticket. Staff-visible only.
Args:
ticket_id: Ticket identifier, e.g. "481".
note: One or two sentences the next reader can act on.
"""
record = TICKETS.get(ticket_id)
if record is None:
raise ResourceError(f"no ticket {ticket_id}")
if len(note) > 500:
raise ResourceError("note too long; keep it under 500 characters")
record["notes"].append(note)
return f"note added to {ticket_id}; {len(record['notes'])} total"
if __name__ == "__main__":
server.run("stdio")两个刻意的设计:
add_internal_note不能改派、不能关单。收窄范围是最便宜的安全控制:不在服务器上的能力,就谈不上被滥用。- 工具在改动状态之前先校验(工单存在、备注长度),被拒绝的调用不留痕迹,纠正输入后可以直接重试。
客户端:连接、列工具、调用——每一步应看到什么
专长 Agent 内嵌 MCP 客户端。连接生命周期就是一个 async with 块:进入时拉起服务器子进程并协商协议版本,退出时断开并结束子进程。
import asyncio
import os
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp_types import TextContent
async def main() -> None:
params = StdioServerParameters(
command="python",
args=["ticket_server.py"],
# 从环境注入只读员工 token,不写进代码。
env={"TICKET_API_TOKEN": os.environ["TICKET_API_TOKEN"]},
)
async with Client(stdio_client(params)) as client:
tools = await client.list_tools()
print([t.name for t in tools.tools]) # ['add_internal_note']
result = await client.call_tool(
"add_internal_note",
{"ticket_id": "481", "note": "Reproduced on Safari 17; console shows a TypeError."},
)
texts = [b.text for b in result.content if isinstance(b, TextContent)]
print(result.is_error, texts) # False ['note added to 481; 1 total']
asyncio.run(main())按顺序应观察到:工具列表里恰好一个工具;成功调用返回 is_error=False 和一条文本;工单的 notes 多了一句(读资源确认)。如果 list_tools 出现了没设计过的工具,先停下来查服务器——客户端即将拥有的权限超出了你的设计。
服务器放到远端时:scope 与 token 受众
单机 stdio 下,上面的边界就是进程属主。换成 Streamable HTTP,同一个服务器成为 OAuth 2.1 的资源服务器,边界变成 token:
- 第一次未授权请求,服务器以
401响应,WWW-Authenticate头里指向受保护资源元数据(PRM),客户端走标准的发现与 PKCE 流程(见使用 OAuth 2.1 授权)。 - 给备注工具单独的 scope 并在服务端检查,让只能读的 token 写不了:
| token scope | 资源 tickets://{id} | add_internal_note |
|---|---|---|
tickets:read | 可读 | 拒绝 |
tickets:read tickets:notes | 可读 | 可调用 |
- 校验 audience(受众),不只是签名。为别的 API 签发的 token 在这里碰巧能通过验签,就是 token 透传问题;授权教程展示了受众如何写进 token 并由服务端核对。
本地 stdio 部署维持官方安全页的简单规则:凭据从环境变量读、本机 HTTP 服务器绑定 127.0.0.1、单用户本地工具不要跑浏览器 OAuth 流程。
失败:先分类,再恢复
三类失败的行为完全不同,混为一谈是 Agent 循环里最常见的 bug:
| 你看到什么 | 家族 | 含义 | 恢复 |
|---|---|---|---|
正常 CallToolResult 里 is_error=True | 工具失败 | 工具执行了并拒绝了请求(ResourceError 等);会话仍然可用 | 读消息、改输入、再次调用——这就是设计好的路径 |
JSON-RPC 错误,如 -32601 method not found | 协议错误 | 调用根本没到你的工具:名字写错,或双方修订版不一致 | 改名字或修版本协商;原样重试不可能成功 |
| 连接层错误(会话断开) | 传输失败 | 服务器进程或流挂了 | 用新的 async with 重连;上一次调用的副作用未知——先查工单再重试,或把工具做成幂等 |
之前一直正常,突然 401 | 授权 | token 过期,或受众不对 | 按 RFC 9728 元数据刷新;受众错误是配置问题,重试无意义 |
| 崩溃的工具只留下一句空泛消息 | SDK 隔离 | 工具内部未捕获异常;SDK 不把内部细节(堆栈、密钥)发给客户端 | 修服务器;被扣留的文本永远不会到模型手上 |
最后一行值得记住:模型能处理的失败用 ResourceError 抛出,其余的让它崩——客户端能看到什么由 SDK 的隔离机制决定。资源概念页专门演示了这两种行为。
验证你自己的部署
下面的检查把上面的服务器和客户端端到端跑一遍,每条对应本页的一个观察:
list_tools恰好返回add_internal_note。- 用有效工单调用的返回
is_error=False和备注计数。 - 用不存在的工单调用返回
is_error=True与no ticket消息,且会话仍接受后续调用。 - 超过 500 字符的备注被拒绝,工单保持原样。
场景脚本与页面断言在站点检查套件 a2a-handoff-check/ 下;换成你自己的命令与参数即可对准你的服务器复跑。
2026-10-28 的记录结果是四个 stdio 场景全部通过,解释器中的 SDK 版本为 mcp==2.3.0。这不能证明上文远程 HTTP 授权行为已经实测;该边界需要受控资源服务器以及可接受、应拒绝的两类 token。
本页边界与 A2A 的另一半
本页只覆盖 MCP 侧:资源、受控工具、scope、受众与失败家族。协作的 A2A 一半——专长 Agent 的发现、任务下发、状态跟踪、消息与 artifact 交换——是另一套协议、另一组失败模式,见 a2acn.com 的 A2A 指南。
来源,2026-10-27 对照固定上游修订版 dae694f6 核验:
- 2026-07-28 规范的工具与资源章节
- 搭建 MCP 服务器、构建 MCP 客户端——上文使用的 SDK 模式
- 使用 OAuth 2.1 授权、本地服务器安全默认值——scope、受众、stdio 规则
- 资源概念与错误隔离