跳至内容

实验 06 — MCP

目标: 附上一个 Model Context Protocol 服务器,以便智能体能够使用你未编写过的工具,而且 — 就是同样重要的一样 — 学会证明那些工具确实被使用了。

时间: ~20分钟

前置条件: 实验 05 完成,以及用于 HTTP MCP 服务器的互联网访问。

步骤 1 — MCP 的原因

实验 03 你写了一个工具 @define_tool那是你自己的代码库中能力存在时的正确做法。

MCP 是针对另一种情况:由他人构建并运行的能力。在这种情况下,你不需要编写和维护集成,而是将会话指向一个能够说话的协议的服务器,其工具便可以被模型所使用。

这堂实验使用 Microsoft Learn MCP服务器通过HTTP——就是这个仓库已经为VS Code配置的那个——所以根本不需要安装。

步骤 2 — 配置服务器

打开 mcp_sample.py,并找到会话配置:

        session = await client.create_session(
            model=model_id,
            streaming=False,
            mcp_servers={
                "microsoft.docs.mcp": {
                    "type": "http",
                    "url": "https://learn.microsoft.com/api/mcp",
                    "tools": ["*"],
                }
            },
            on_permission_request=PermissionHandler.approve_all,
        )

💡 纯字典是这里的API。 MCPServerConfig 是一个 TypedDict 组合,不是一个你实例化的类。.NET SDK 需要一个 IDictionary<string, McpServerConfig>,在编译时会拒绝一个松散的字典;Python 只接受字面量。

该联合有两个 结构:

结构 必填字段 在何处使用
MCPHTTPServerConfig type ("http""sse"), url, tools 该服务器可通过网络访问
MCPStdioServerConfig command, tools 该服务器运行为本地子进程

⚠️ tools 是必需的,不是可选的。 使用 ["*"] 允许服务器发布的所有内容,或者列出特定的工具名称来缩小范围。省略该键是类型错误。

💡 type 是 stdio 形式的可选属性。 CLI 会根据 command 的存在推断出 stdio,就像它会根据 url 的存在推断出远程服务器一样。可选的 stdio 键包括 argsenvtimeoutworking_directory ——请注意,最后一个键是 working_directory,而不是 cwd。SDK 在将数据转换为传输格式的过程中将它重命名为 cwd,因此今天你通过传递 cwd 来使用它,但这并不是类型化的公共 API。

⚠️ on_permission_request 也与此有关。实验 03所示,当未提供处理程序时,Python 会拒绝工具调用。PermissionHandler.approve_all 对于实验来说是合适的;但它对于涉及真实数据的操作来说是不合适的。

步骤 3 — 运行

cd src/AgentOrchestrator-python
uv run python -m sdk_labs mcp

添加 --model <id> 来覆盖模型:

uv run python -m sdk_labs mcp --model gpt-5-mini

验证输出:

== Lab 06: mcp ==

Model: claude-haiku-4.5
Prompt: asking the model to consult Microsoft Learn docs

  [mcp] session.mcp_server_status_changed
  [mcp] session.mcp_servers_loaded
  [mcp] session.mcp_server_status_changed
  [tool-request] assistant.message: microsoft.docs.mcp/microsoft_docs_search
  [tool] tool.execution_start: microsoft.docs.mcp/microsoft_docs_search
  [tool] tool.execution_complete: microsoft.docs.mcp/microsoft_docs_search (success=True)

Assistant: Let me fetch the main Azure Container Apps documentation page for a clearer overview:
  [tool-request] assistant.message: microsoft.docs.mcp/microsoft_docs_fetch
  [tool] tool.execution_start: microsoft.docs.mcp/microsoft_docs_fetch
  [tool] tool.execution_complete: microsoft.docs.mcp/microsoft_docs_fetch (success=True)

Assistant: **Azure Container Apps** is a serverless platform for running containerized applications without managing underlying infrastructure—you provide your container, and Azure handles the servers, orchestration, and deployment. It automatically scales based on demand (HTTP traffic, events, or resource load) and supports microservices, APIs, background jobs, and event-driven workloads with built-in features like traffic splitting, secrets management, and HTTPS ingress.

✅ MCP tool(s) invoked: microsoft.docs.mcp/microsoft_docs_fetch, microsoft.docs.mcp/microsoft_docs_search

两款真实的 MCP 工具运行 — microsoft_docs_search 用于查找相关页面,然后是 microsoft_docs_fetch 用于读取一个。最后一条确认了这一点,并且示例退出

💡 .NET 技术路线记录了不同的结果。 在那次运行中,Learn 服务器加载了,但从未呈现其工具,因此模型退回到内置的web_fetch,示例退出非零状态。同样的服务器,同样的协议,不同的结果——这正是下一步存在的原因。

步骤 4 — 证明工具确实被使用了

这是实验的真实教训。Azure Container Apps 是公开知识,因此一个模型可以生成一个自信、合理且正确引用的答案,而无需调用任何 MCP 工具。一个好答案不是证据。

样本订阅构成实际证据的事件:

  • session.mcp_servers_loaded — 配置被接受
  • session.mcp_server_status_changed — 服务器连接的状态发生了变化
  • mcp.tools.list_changed — 服务器发布了其工具列表
  • external_tool.requested — 助手请求了一个外部工具
  • tool.execution_start / tool.execution_complete — 一个工具实际上运行了

关键在于工具是如何被判定为 MCP. tool.execution_start 带有 mcp_server_name的,只有那些设置为该值的执行才会被统计:

生成的 ToolExecutionStartData 携带 tool_call_id, tool_name, arguments, mcp_server_name,并且 mcp_tool_name 其中包含的字段。只有MCP特定的字段证明该工具来自配置好的MCP服务器。

                    # Only a tool carrying an MCP server name came from MCP.
                    # Built-ins such as web_fetch must not count, or an
                    # unreachable server still reports success.
                    if evt.data.mcp_server_name:
                        mcp_tools.add(tool_name)

                    invoked_tools.add(tool_name)

如果MCP没有运行,样本会这么说并且退出非零状态:

    if not mcp_tools:
        print()
        print("⚠️  No MCP tool was invoked.")
        if invoked_tools:
            print(f"    The model used non-MCP tool(s) instead: {', '.join(sorted(invoked_tools))}")
        print("    The answer may have come from the model's own knowledge or a built-in")
        print("    tool rather than Microsoft Learn. Check the server is reachable and that")
        print("    its tools were loaded — look for the [mcp] lines above.")
        return 1

⚠️ 统计错误比不检查更糟糕。 一个较早的版本 统计了 任何 工具执行并愉快地报告了 ✅ MCP tool(s) invoked: web_fetch — 对与MCP无关的工具的成功进行了报告。这制造了对一个有缺陷集成的信心。

💡 设置 SDKLABS_TRACE_EVENTS=1 在调试一个无法连接的服务器时,以打印出每一个事件,而不仅仅是 MCP 相关的事件:

SDKLABS_TRACE_EVENTS=1 uv run python -m sdk_labs mcp

步骤 5 — 比较编辑器配置

本仓库已在以下文件中为 VS Code 配置同一服务器: .vscode/mcp.json:

{
  "servers": {
    "microsoft.docs.mcp": {
      "type": "http",
      "url": "https://learn.microsoft.com/api/mcp"
    }
  }
}

该文件供编辑器使用。mcp_servers= 参数用于你在应用程序或示例中运行的 Copilot SDK 会话。不同的消费者,相同的服务器,相同的协议。

这就是MCP的目标:一种协议,多种客户端。同一个工具服务器可以为编辑器、一个CLI、一个测试框架或应用程序代理提供服务。

保持两种配置在你的思维模型中分离:编辑 MCP 设置帮助你的开发工具,而 mcp_servers= 变化这个 SDK 会话可以提供的模型。

Step 6 — 消费 MCP 以及 服务

所有这些都指向了别人服务器上的会话。这个仓库同样也实现了这一点,并且值得理解其原因。

到目前为止,CopilotChatService 将你的提示发送给模型时,无法访问应用程序自身的数据。询问聊天“我的最高消费客户是谁?”它只能猜测——零售数据库对它而言是不可见的。

mcp_server/ 关闭了这一缺口,提供了五个只读工具来访问 retail.db:

工具 答案
list_transactions "显示C003的最近购买记录
get_transaction "什么交易是7号?
list_segments "我们有哪些段落呢?"
get_customer_summary "C001总共花费了多少?"
predict_segment "C003 属于哪个部分?"

copilot_chat.py 作为标准输入输出服务器(stdio server)附加上述结构:

{
    "retail-analytics": {
        "command": sys.executable,      # same interpreter as the API
        "args": ["-m", "mcp_server"],
        "working_directory": str(PROJECT_ROOT),
        "tools": ["*"],
    }
}

有两个设计决策是这里真正要学习的教训。

1. 连接权限最小化,不仅仅是代码层面。 引擎以 mode=ro 打开 SQLite,因此写操作被驱动程序拒绝:

create_engine("sqlite:///file:" + str(path) + "?mode=ro&uri=true")

即使一个指令被嵌入到提示中,也无法修改数据,因为该能力从未被授予。将此与‘我们根本就没有编写任何插入语句’进行比较,后者是一种惯例,而非控制。

2. 域工具,而非原始 SQL。 模型获取 get_customer_summary,而非 run_query。一个通用的 SQLite MCP 服务器将是一行代码,但它也给了模型一个任意 SQL 的逃生门。工具面 就是 安全边界,所以保持它小巧且具体。

注意做了什么 变化:REST API 仍然直接通过<code>RetailAnalyticsService</code>读取数据库 RetailAnalyticsServiceMCP 是为 模型,而不是让应用通过它访问自身数据库——通过 LLM 工具协议路由应用自己的 CRUD 操作只会增加一次子进程跳转,并无谓地牺牲事务和类型安全。

试一试:

cd src/AgentOrchestrator-python
uv run uvicorn app.main:app --port 5070
curl -s -X POST http://localhost:5070/api/chat \
  -H "Content-Type: application/json" \
  -d '{"prompt":"What is customer C003 total spend and which segment are they in?"}'

观察 API 日志以获取证明,完全与 Step 4 中一样:

INFO:app.services.copilot_chat:MCP tool call: retail-analytics/get_customer_summary
INFO:app.services.copilot_chat:MCP tool call: retail-analytics/predict_segment

💡 权限处理器是受限制的,而不是全面的。 approve_all 在控制台实验中是合适的,但这个服务可以从浏览器访问,因此它只批准来自 retail-analytics 的只读工具,并拒绝其他一切:

if (
    isinstance(request, PermissionRequestMcp)
    and request.server_name == RETAIL_MCP_SERVER
    and request.read_only
):
    return PermissionDecisionApproveOnce()

⚠️ 捕捉

  • tools 都必须 在两种配置形状中;使用 ["*"] 一切
  • 省略on_permission_request会导致Python中的工具调用被拒绝
  • 学习服务器可以通过网络访问。 如果无法访问,模型没有 MCP 工具,并且可能会安静地从自己的知识中回答——因此出现非零退出状态
  • 一个合理的答案不能作为证明。 只有在工具执行时出现 mcp_server_name 证明了 MCP 工具已经运行。
  • MCP 服务器是第三方代码,可以暴露强大的能力。在将智能体指向处理真实工作的服务器之前,验证该服务器、其权限以及其数据访问。
  • working_directory, 不是 cwd, 在 stdio 配置中 — SDK 会为你进行重命名,且只有公钥会被进行类型检查
  • 一个只读的提示并不意味着只读的保证. read_only_hint告诉主机它可以自动批准是安全的;实际上阻止在mcp_server/中写入的是mode=ro的SQLite连接

💡 拓展练习

  1. 聚焦 tools["*"] 单一的工具名称,并观察模型适应
  2. 添加一个第二个 MCP 服务器,并比较模型在选择工具集时的表现
  3. 将服务器名称传递到 disabled_mcp_servers=[...],重新运行相同的提示,并将答案与和不使用 Learn 工具的对比
  4. 将 HTTP 配置替换为 stdio 配置—— {"type": "stdio", "command": "...", "tools": ["*"]} 与您已有的任何本地 MCP 服务器对比
  5. 探索 mcp_oauth_token_storage, github_mcp_tool_config, 和 enable_mcp_apps 以支持认证或更丰富的 MCP 情形
  6. PermissionHandler.approve_all 替换为一个记录并拒绝您不想让模型使用的工具的处理程序

✅ 检查点

您可以现在解释:

  • [x] MCP 与你在 Lab 03 中自己编写的工具有何不同
  • [x] 当使用 HTTP 配置结构还是 stdio 结构
  • [x] 那 mcp_servers 使用普通的字典因为配置是 TypedDict,以及那 tools 是必需的
  • [x] 一个合理的答案并不能证明MCP工具是否被调用了
  • [x] 如何通过 mcp_server_name 区分一个真实的 MCP 工具和内置工具
  • [x] 如何使 .vscode/mcp.json 和 SDK 配置指向同一服务器
  • [x] 为什么这个仓库既作为 MCP 服务器使用,又作为 MCP 客户端使用,以及为什么 REST API 仍然直接与数据库通信
  • [x] 为什么mode=ro才是真正的控制,而read_only_hint只是个提示