MCP
这一页讲 MCP(Model Context Protocol)适配器:怎样把操作发布成大模型可调用的 tool,tools/list 里的名字、Schema、标题描述与注解从哪里来,tools/call 怎样经过完整管道并区分业务失败与协议错误,HTTP 与 stdio 两种传输各自的规则,以及可配置的选项。读完你能让 Claude、Cursor 这类 MCP 客户端直接调用你的业务操作,而不另写一个 MCP 服务端。前置知识是操作契约与上一页 gRPC 开头的「一份契约,多个载体」。
为什么需要
要让大模型调用业务系统,通常得为每个功能手写一个 tool:名字、描述、参数 Schema、调用代码,再处理认证与错误。这些信息业务系统其实都有,只是分散在控制器、DTO、注释与权限配置里,手写一遍就多一份会过期的拷贝,而且 tool 的实现往往绕过了业务层的授权与校验。
框架把 MCP 当作操作契约的又一个载体:tool 名由操作 ID 推出,inputSchema / outputSchema 就是契约的 JSON Schema,标题与描述来自消息目录并按调用方语言翻译,注解按动词推导。每个 tools/call 在自己的服务作用域里经完整内核管道执行,认证、按契约授权、校验与事务和 HTTP 上完全一样,大模型拿不到比当前用户更多的权限。
tool 名与 tools/list
tool 名按 {resource}_{verb} 推导(orders.create → orders_create),显式覆盖写在配置 XiHan:Protocols:Mcp:Tools:<操作 ID>。名字必须匹配 ^[A-Za-z0-9_-]{1,64}$,不合法时以 PROTOCOLS_VALIDATION_004、两个操作同名时以 PROTOCOLS_CONFLICT_001 启动失败。tools/list 不分页,每个 tool 的字段:
| 字段 | 来源 |
|---|---|
name | tool 名 |
title / description | 消息目录 <操作 ID>.title / <操作 ID>.description(操作 ID 里的 - 换成 _),按调用方区域翻译;没有就不给 |
inputSchema | 请求 Schema;Schema 的 type 不是 object 或操作没有 Schema 时为 {"type":"object"} |
outputSchema | 响应 Schema,仅当其 type 是 object |
annotations | 按契约推导,见下表 |
| 契约 | readOnlyHint | destructiveHint | idempotentHint |
|---|---|---|---|
只读(get、list,或 ReadOnly = true 的非标准动词) | true | false | true |
create | false | false | false |
update | false | false | true |
delete | false | true | true |
| 其他动词 | false | true | false |
只读必须在 [Operation] 上显式声明:没有声明的查询类自定义动词(如 orders.search)按 MCP 的默认标成可能破坏,客户端会要求用户确认。描述里不写授权要求,由协议适配器统一呈现。
最小示例
我们给 SimpleApp 加上 MCP。三步:引用 XiHan.Framework.Protocols.Mcp 并装配 McpProtocolFeature;在消息目录里给要暴露的操作写标题与描述;UseXiHan() 之后调用 MapXiHanMcp()。
dotnet add package XiHan.Framework.Protocols.Mcp --prerelease操作在 Expose 里带上 Mcp(上一页的 CreateOrder 已经带了)。标题与描述放在应用的语言文件里,每种语言一个文件,写法见全球化和本地化。这是 Localization/en-US.json:
{
"orders.create.title": "Place an order",
"orders.create.description": "Place an order with a name and an amount.",
"orders.list.title": "List orders",
"orders.list.description": "List the orders of the current tenant in creation order."
}using SimpleApp;
using SimpleApp.Orders;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Kernel.Hosting;
using XiHan.Framework.Localization.Abstractions;
using XiHan.Framework.Localization.Abstractions.Messages;
using XiHan.Framework.Profiles.Standard;
using XiHan.Framework.Protocols.Http;
using XiHan.Framework.Protocols.Http.Routing;
using XiHan.Framework.Protocols.Mcp;
using XiHan.Framework.Protocols.Mcp.Transports;
var builder = WebApplication.CreateBuilder(args);
builder.AddXiHan(xihan => xihan
.UseStandardProfile()
.UseFeature<HttpProtocolFeature>()
.UseFeature<McpProtocolFeature>()
.UseFeature<OrdersFeature>());
// 应用自己的消息目录:tool 的 title 与 description 从这里取
builder.Services.AddMessageCatalog(MessageCatalog.FromEmbeddedResources("samples", typeof(SampleMessages)));
var app = builder.Build();
app.UseXiHan();
app.MapXiHanOperations();
app.MapXiHanMcp(); // POST /mcp:initialize、ping、tools/list、tools/call
app.Run();运行后先列 tool,再以 bob 的凭据下单:
curl -s -X POST localhost:5000/mcp -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -s -X POST localhost:5000/mcp -H "Content-Type: application/json" -H "Authorization: ApiKey dev-bob-key" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"orders_create","arguments":{"name":"widget","amount":3}}}'第一个请求返回 orders_create 与 orders_list 两个 tool,带英文标题与描述(加 Accept-Language: zh-CN 头就是中文)。bob 只有 orders.read,第二个请求的答复不是协议错误,而是业务失败:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "SECURITY_DENIED_004: Permission 'orders.write' is missing." }],
"isError": true
}
}换成 alice(dev-alice-key,持有 orders.write)再调一次,content[0].text 是新订单的 JSON,同时给出 structuredContent。
tools/call 的语义
arguments(缺省 {},必须是对象)就是操作的载荷。每个 tools/call 是一次独立调用:以承载请求的头部建新的调用上下文,在自己的服务作用域里经完整管道执行,工作单元与作用域服务不与外层 HTTP 请求共享;调用来源记协议 mcp 与 tool 名。
成功时 content[0].text 是响应 JSON,响应是对象时同时给 structuredContent。操作失败(校验、未授权、冲突)不是协议错误,而是 isError: true 的结果,文本是 <错误码>: <文案>,文案按调用方区域翻译。这样区分的原因:大模型需要读到业务失败的原因来调整下一步(改参数、换 tool、告诉用户没权限),而协议错误只说明请求本身不合法。
Streamable HTTP 传输与 JSON-RPC 错误
消息为 JSON-RPC 2.0,HTTP 上是 Streamable HTTP 传输:POST 一条消息一个答复(application/json);通知(没有 id,如 notifications/initialized)与客户端发来的响应不答复,回 202;不开 SSE 流、不建会话,GET 与 DELETE 为 405。请求带的 MCP-Protocol-Version 头与服务端版本不同时回 400,缺失按当前版本处理。
/mcp 本身是经过 UseXiHan() 的普通端点:请求凭据在外层认证,凭据无效时整个请求以 401 Problem Details 拒绝、带 WWW-Authenticate 质询,不进入 JSON-RPC;应用未就绪时 503。没有凭据的请求照常进入,按契约的授权在每个 tools/call 的管道里判定,所以匿名客户端能 tools/list,调需要权限的 tool 时得到 isError。
| 情形 | error.code | error.data.code | HTTP |
|---|---|---|---|
| 请求体不是 JSON | -32700 | PROTOCOLS_VALIDATION_001 | 400 |
不是 JSON-RPC 2.0 请求(非对象、jsonrpc 不是 2.0、缺 method、id 为 null、params 不是对象) | -32600 | PROTOCOLS_VALIDATION_001 | 400 |
MCP-Protocol-Version 不受支持 | -32600 | PROTOCOLS_VALIDATION_002 | 400 |
| 方法不受支持 | -32601 | PROTOCOLS_NOTFOUND_001 | 200 |
tools/call 缺 name | -32602 | PROTOCOLS_VALIDATION_001 | 200 |
| tool 未登记 | -32602 | PROTOCOLS_NOTFOUND_002 | 200 |
arguments 不是对象 | -32602 | PROTOCOLS_VALIDATION_003 | 200 |
stdio 传输
桌面 MCP 客户端常以子进程方式启动服务端,经 stdin / stdout 通信。装配了 McpProtocolFeature 与 AddXiHanCommandLine() 的命令行宿主提供 mcp 命令:stdin 与 stdout 每行一条 JSON-RPC 消息,日志只写 stderr,stdin 关闭即结束会话。stdin 的字节按 UTF-8 解码,与控制台代码页无关(Windows 上重定向的标准输入不按 936、437 等代码页解码,中文参数不会成乱码);答复里的非 ASCII 字符按 JSON 转义成 \uXXXX,只含 ASCII,按任何控制台输出代码页写出都是合法的 UTF-8。
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | dotnet run --project framework/samples/SimpleCli -- mcpmcp 命令接受与 invoke 相同的上下文选项,作用于整个会话里的每个 tools/call:
| 选项 | 作用 |
|---|---|
--authorization '<scheme> <value>' 或环境变量 XIHAN_AUTHORIZATION | 凭据;选项优先。MCP 规范要求 stdio 服务端从运行环境取凭据,所以客户端配置里通常写环境变量 |
--tenant <id>、--correlation <id> | 租户与关联 ID |
--locale <区域> | tools/list 的标题与描述、失败文案按它翻译 |
--timeout <秒> | 每个 tools/call 的时限 |
XIHAN_AUTHORIZATION="ApiKey dev-alice-key" dotnet run --project framework/samples/SimpleCli -- mcp --tenant acme --locale zh-CN每个 tools/call 经完整管道,凭据在这次调用的 Authenticate 阶段认证;stdio 没有外层认证,凭据无效时需要认证的 tool 得到 isError(SECURITY_DENIED_003),会话继续。选项不合法(凭据不是 <scheme> <value>、时限不是正数)时不进入会话,stderr 写一行用法错误,退出码 64。调用来源只有协议 mcp 与 tool 名。宿主的搭建见命令行宿主。
选项
配置节 XiHan:Protocols:Mcp(McpProtocolOptions):
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
ServerName | string | 宿主的应用名 | initialize 答复的 serverInfo.name |
ServerVersion | string | 框架版本 | serverInfo.version |
Instructions | string | 空 | initialize 答复的 instructions,给大模型的使用说明,空时不写 |
Tools:<操作 ID> | string | — | 显式 tool 名 |
initialize 总是答复本服务端的协议版本与 capabilities.tools.listChanged = false,由客户端决定是否继续;ping 答复 {}。
常见问题
tool 列表里没有我的操作
依次检查:[Operation] 的 Expose 是否含 Mcp;Stability 是否为 Internal(内部操作不在任何协议上发布);操作所在的特性是否 UseFeature 了、Configure 里是否调用了 AddGeneratedOperations()。用命令行宿主的 operations 命令能直接看到每个操作暴露在哪些协议上。
标题与描述是空的
tools/list 只在消息目录里有 <操作 ID>.title / <操作 ID>.description 键时才输出这两个字段。确认语言文件里有这两个键、文件以 LogicalName="Localization/%(Filename)%(Extension)" 嵌入、AddMessageCatalog 已注册。操作 ID 带连字符时键里要换成下划线:tenant-members.list 的键是 tenant_members.list.title。
客户端每次都要求确认我的查询 tool
MCP 客户端按 destructiveHint 决定是否确认。只有 get、list 两个动词自动算只读,search、export 这类自定义动词要在 [Operation] 上写 ReadOnly = true,否则按默认标成可能破坏。
能用 SSE 或长连接吗
不能。这个实现只做 Streamable HTTP 的请求 / 响应部分:一条 POST 一个答复,不开 SSE 流、不建会话。服务端到客户端的通知与 listChanged 都不支持,tool 列表只在重启后变化。
大模型会不会拿到比用户更多的权限
不会。每个 tools/call 带着这次 HTTP 请求(或 stdio 会话)的凭据经完整管道,Authorize 阶段按契约里的 Permission / Policy 判定,和 HTTP 上调同一个操作完全一样。凭据没有的权限,tool 也没有。
相关包与下一步
- 包参考:Protocols.Mcp、Hosting.CommandLine
- 标题与描述的来源:全球化和本地化
- 契约与 Schema:操作契约
- 用 MCP 开发框架本身(给 Agent 的 MCP Server):MCP Server
- 规范:RFC-0011《操作契约与 XGI》
- 下一步:CloudEvents,把 HTTP 上的事件接进消息子系统
