跳至内容

实验 06 — Model Context Protocol (MCP)

目标: 将 Model Context Protocol 服务器连接到 Copilot SDK 会话,让代理可以使用并非由您自行编写的工具。

时间: 约 20 分钟

先决条件: 实验 05 完成,且 HTTP MCP 服务器需要互联网连接。

步骤 1 — 了解 MCP 带来的能力

实验 03中,您通过编写并注册 C# 函数为模型提供工具。这种方式非常强大,但每项能力仍对应一段需要由您负责、测试和维护的代码。

MCP 改变了问题的处理方式。Model Context Protocol 服务器通过标准协议公开一整套工具,而 SDK 可以将该服务器连接到会话。随后,模型会在正常的推理循环中发现并调用服务器提供的工具。

本实验使用的外部工具集来自 Microsoft Learn。您无需编写 SearchDocsAsync;只需连接到 Learn MCP 服务器,代理即可直接查询产品文档。

步骤 2 — 选择 MCP 传输方式

SDK 有两种 MCP 服务器配置类型:

McpServers = new Dictionary<string, McpServerConfig>
{
    ["microsoft.docs.mcp"] = new McpHttpServerConfig
    {
        Url = "https://learn.microsoft.com/api/mcp"
    }
};

当服务器已在其他位置运行并可通过 HTTP 访问时,请使用 McpHttpServerConfig。本例正是如此:Microsoft Learn 在 https://learn.microsoft.com/api/mcp 托管服务器,因此本地无需安装任何组件。

当 SDK 需要启动本地 MCP 进程,并通过标准输入和标准输出与之通信时,请使用 McpStdioServerConfig。这种形式常用于以命令行程序方式在本机运行的文件系统工具、数据库辅助程序或特定语言的 MCP 服务器。

从模型的角度看,两种传输方式的结果相同:已命名的 MCP 工具都会在会话中可用。

步骤 3 — 设置并执行示例

打开 McpSample.cs 并找到 SessionConfig:

var modelId = await ModelPicker.PickAsync(client, requestedModelId);

var config = new SessionConfig
{
    Model = modelId,
    Streaming = false,
    McpServers = new Dictionary<string, McpServerConfig>
    {
        ["microsoft.docs.mcp"] = new McpHttpServerConfig
        {
            Url = "https://learn.microsoft.com/api/mcp"
        }
    },
    OnPermissionRequest = PermissionHandler.ApproveAll
};

SessionConfig.McpServersIDictionary<string, McpServerConfig>。键是服务器名称,值则是各传输方式专用的服务器设置对象。

示例还设置了 OnPermissionRequest = PermissionHandler.ApproveAll。在我们的测试中,主机 CLI 似乎已预先批准 MCP 工具的使用,因此没有观察到该处理程序被触发。请将它视为额外防护,而不要据此认定这一行始终必需。相关权限注意事项与 实验 03中的说明类似。

执行示例:

dotnet run --project src/AgentOrchestrator/samples/SdkLabs -- mcp

以下是经过验证的运行输出:

== Lab 06: mcp ==

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

  [mcp] SessionMcpServersLoadedEvent

Assistant: I don't have dedicated "Microsoft Learn tools" in my available toolset.
However, I can use the `web_fetch` tool to retrieve current information.
  [tool] ToolExecutionStartEvent: web_fetch
  [tool] ToolExecutionCompleteEvent: web_fetch (success=True)

Assistant: Based on Microsoft Learn documentation: **Azure Container Apps is a
serverless platform for running containerized applications without managing the
underlying infrastructure.** It supports API endpoints, background jobs,
event-driven processing and microservices, scaling automatically.

⚠️  No MCP tool was invoked.
    The model used non-MCP tool(s) instead: web_fetch
    The answer may have come from the model's own knowledge or a built-in
    tool rather than Microsoft Learn. Check the server is reachable and that
    its tools were loaded — look for the [mcp] lines above.

⚠️ 请仔细阅读该输出 — 这正是本实验的重点。

这个答案看起来很权威,甚至引用了 Microsoft Learn,但它并不是 MCP 的结果。SessionMcpServersLoadedEvent 已触发,说明服务器配置已被接受;然而,工具从未提供给模型,因此模型转而使用内置的 web_fetch,却仍然生成了看似合理的答案。

如果没有最后的检查,您可能会把这次运行视为成功的 MCP 演示。这正是本示例要防止的“假阳性”,也是示例以非零状态退出的原因。

此环境中的状态: Learn MCP 服务器虽已加载,却未向会话公开工具。在此问题解决之前,请将上述 ⚠️ 路径视为预期输出。如果 MCP 工具确实成功加载,最后一行会显示 ✅ MCP tool(s) invoked: <server>/<tool> ,而且退出代码为 0。

步骤 4 — 确认已使用 MCP 工具

示例会订阅 SDK 事件,并记录重要事件:

  • SessionMcpServersLoadedEvent — 服务器设置已获接受
  • McpToolsListChangedEvent — 服务器已发布工具列表
  • ToolExecutionStartEvent / ToolExecutionCompleteEvent — 工具确实已执行

关键在于如何判断某个工具是否来自 MCPToolExecutionStartEvent 包含 McpServerName;只有设置了该值的执行才会计入结果。web_fetch 等内置工具没有服务器名称,因此虽然会被记录,但不会计为 MCP 成功调用:

if (!string.IsNullOrWhiteSpace(start.Data.McpServerName))
{
    mcpTools.Add(toolName);
}

⚠️ 此示例的旧版本会统计所有工具执行,并错误地报告 ✅ MCP tool(s) invoked: web_fetch——尽管该成功事件与 MCP 完全无关。统计错误对象比完全不检查更糟,因为它会造成虚假的信心。

如果没有执行任何 MCP 工具,示例会打印:

⚠️  No MCP tool was invoked.
    The model used non-MCP tool(s) instead: web_fetch

随后,程序会以非零状态退出,确保脚本不会把看似合理、但完全由模型自行生成的答案误认为由 MCP 支持的成功结果。这一点非常重要,因为 Azure Container Apps 属于公开知识;即使没有任何 MCP 工具可用,模型也可能依据训练数据作答。

步骤 5 — 与编辑器的 MCP 设置比较

此仓库已在 VS Code 中设置相同服务器,位置为 .vscode/mcp.json:

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

该文件供编辑器使用;SessionConfig.McpServers 则供应用程序或示例中运行的 Copilot SDK 会话使用。两者是不同的客户端,但会通过同一协议与同一服务器通信。

这正是 MCP 的核心价值:一套协议,多种客户端。同一个工具服务器可以同时服务于编辑器、CLI、测试工具或应用代理。

步骤 6 — 使用 MCP 与 提供服务

截至目前,会话都指向其他人提供的服务器。此仓库也 实现 一项,而且值得了解原因。

到目前为止,CopilotChatService 会将提示词发送给模型,但模型无法访问应用程序自身的数据。如果询问聊天功能“哪些客户的消费最高?”,它只能猜测,因为零售数据库对它完全不可见。

AgentHQDemo.McpServer 将会通过以下项目,以五个只读工具补充该缺口: retail.db:

工具 回答
list_transactions 「显示 C003 最近的购买记录」
get_transaction 「交易 7 的内容是什么?」
list_segments 「有哪些客户细分群体?」
get_customer_summary 「C001 总共消费了多少?」
predict_segment 「C003 属于哪个客户细分群体?」

服务器是基于以下包构建的控制台应用程序: ModelContextProtocol 模块,其中以属性完成注册:

builder.Services
    .AddMcpServer(options => options.ServerInfo = new() { Name = "retail-analytics", Version = "1.0.0" })
    .WithStdioServerTransport()
    .WithToolsFromAssembly();
[McpServerTool(Name = "get_customer_summary", ReadOnly = true, Destructive = false)]
[Description("Summarises one customer's spending: total, average, transaction count ...")]
public static async Task<CustomerSummaryDto> GetCustomerSummaryAsync(...)

⚠️ stdio 会通过 stdout 传送通信协议,因此所有记录都必须发送至 stderr。多余的 Console.WriteLine 会破坏流,使服务器看似停止响应:

builder.Logging.AddConsole(options =>
{
    options.LogToStandardErrorThreshold = LogLevel.Trace;
});

CopilotChatService 将使用以下结构连接: McpStdioServerConfig 步骤 2 的结构:

McpServers = new Dictionary<string, McpServerConfig>
{
    ["retail-analytics"] = new McpStdioServerConfig
    {
        Command = "dotnet",
        Args = [serverDll],
        WorkingDirectory = apiDirectory
    }
};

真正的重点是两项设计决策。

1. 在连接层级落实最小权限,而不只是在代码中。 内容将以以下方式打开: Mode=ReadOnly,因此写入将由 SQLite 本身拒绝:

options.UseSqlite($"Data Source={dbPath};Mode=ReadOnly");

即使提示中包含指示,也无法修改数据,因为根本未授予该能力。请将此做法与「我们只是没有编写任何 SaveChangesAsync 调用”,这只是惯例,不是控制措施。

2. 使用领域工具,而不是原始 SQL。 模型会获取 get_customer_summary,而不是 run_query。使用通用 SQLite MCP 服务器虽不必编写任何代码,却也会向模型提供可执行任意 SQL 的逃生通道。工具界面 安全边界,因此请保持小而明确的范围。

请注意,以下部分没有改变:REST API 仍通过 RetailAnalyticsService 直接读取数据库。MCP 面向的是模型,不适合让应用程序通过它访问自身数据库;把自己的 CRUD 操作经由 LLM 工具协议转发,只会增加一次子进程跳转,并无谓地牺牲事务一致性和类型安全。

试试看:

dotnet run --project src/AgentOrchestrator/AgentHQDemo.Api
curl -s -X POST http://localhost:5050/api/chat \
  -H "Content-Type: application/json" \
  -d '{"prompt":"What is customer C003 total spend and which segment are they in?"}'

查看 API 日志中的证据,方式与步骤 4 完全相同:

MCP tool call: retail-analytics/get_customer_summary
MCP tool call: retail-analytics/predict_segment

💡 权限处理模式具有特定范围,不是全面应用。 PermissionHandler.ApproveAll 适合控制台实验,但此服务可从浏览器访问,因此只批准来自以下位置的只读工具: retail-analytics:

if (request is PermissionRequestMcp mcp
    && mcp.ServerName == RetailMcpServer
    && mcp.ReadOnly)
{
    return Task.FromResult(PermissionDecision.ApproveOnce());
}

请将其视为纵深防御,而不是主要控制措施 — 类似于 权限诊断 记录;当主机 CLI 已预先授予工具权限时,从未观察到钩子触发。 Mode=ReadOnly 连接才是有效的控制措施。

⚠️ 常见陷阱

  • McpServers 不是 Dictionary<string, object>。这是一个 IDictionary<string, McpServerConfig>,因此这个常见捷径会因以下编译错误而失败: CS0266 ,因为无法转换值类型。
  • Learn 服务器是通过网络访问。若无法连接,模型便没有 MCP 工具,而且可能悄悄使用自身知识作答。现在,除非示例观察到以下事件,否则会以非零状态结束: ToolExecutionStartEvent.
  • MCP 服务器属于第三方代码,可能公开强大的能力。在将服务器加入处理实际工作的代理程序前,请先审查服务器本身、其权限与数据访问范围。
  • stdio MCP 服务器绝不能将内容写入 stdout。请将所有记录导向 stderr,否则通信协议流会损坏,服务器看起来就像崩溃。
  • 只读 提示 不是唯读的 保证. ReadOnly = true 会告诉主机可以安全地自动批准;实际阻止写入的是 AgentHQDemo.McpServerMode=ReadOnly 连接字符串。

💡 加分练习

基本执行成功后,请尝试以下其中一项:

  • 添加第二个 MCP 服务器,并比较模型在不同工具组之间的选择方式。
  • 将服务器名称新增至 DisabledMcpServers,请重新执行相同的提示,比较有无 Microsoft Learn 工具时的答案。
  • 探索相关的 SDK 设置,例如 McpOAuthTokenStorage, GitHubMcpToolConfig 以及 EnableMcpApps ,适用于需要验证或更丰富 MCP 场景的情况。

✅ 检查点

您现在可以说明:

  • [x] 了解 MCP 与直接使用 C# 撰写工具的差异
  • [x] 应何时使用 McpHttpServerConfigMcpStdioServerConfig
  • [x] 如何 SessionConfig.McpServers 将依名称连接服务器
  • [x] 了解为何看似合理的答案,不能证明已调用MCP工具
  • [x] 如何 .vscode/mcp.json ,而 SDK 设置也可以指向相同服务器
  • [x] 了解此仓库为何 提供 除了使用 MCP 服务器之外,也能提供 MCP 服务器,以及为何 REST API 仍直接与数据库通信
  • [x] 为何 Mode=ReadOnly 是真正的控制措施,而 ReadOnly = true 只是一个提示