让专长 Agent 通过 MCP 读取工单并调用受控工具:资源、权限边界与失败恢复

让专长 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 的隔离机制决定。资源概念页专门演示了这两种行为。

验证你自己的部署

下面的检查把上面的服务器和客户端端到端跑一遍,每条对应本页的一个观察:

  1. list_tools 恰好返回 add_internal_note。
  2. 用有效工单调用的返回 is_error=False 和备注计数。
  3. 用不存在的工单调用返回 is_error=True 与 no ticket 消息,且会话仍接受后续调用。
  4. 超过 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 核验: