XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

gRPC、MCP 与 CloudEvents ​

本页讲 HTTP 之外的三个协议适配器:gRPC 把操作发布成一元方法,MCP 把操作发布成大模型可调用的 tool,CloudEvents 把 HTTP 上的事件接进消息子系统并把消息推给别的服务,适用于同一个应用要服务多种调用方的场景。各包的完整 API 见 Protocols.Grpc、Protocols.Mcp、Protocols.CloudEvents。

一份契约,多个载体 ​

gRPC 与 MCP 暴露的是操作契约,与 HTTP 适配器读同一个操作登记表、同一份由 Kernel.Generators 生成的请求 / 响应 JSON Schema:HTTP 拿它生成 OpenAPI,gRPC 拿它推出 protobuf 线格式与 .proto,MCP 把它原样作为 tool 的 inputSchema / outputSchema。每次调用都以自己的调用上下文经过完整的内核管道,认证、按契约授权、校验、事务与 HTTP 上完全相同。操作写在哪些协议上由 [Operation] 的 Expose 决定,写法见操作契约;Stability.Internal 的操作不在任何协议上发布。

CloudEvents 不暴露操作:它是消息子系统信封(CloudEvents 1.0)的 HTTP 绑定,入站事件按类型映射到主题、交给 IMessageHandler,出站把信封 POST 到别的服务。

适配器暴露条件映射端点
Protocols.GrpcExpose.Grpc,且有 Schema服务 {Resource}、方法 {Verb}MapXiHanGrpc()、MapXiHanProto()(GET /operations.proto)
Protocols.McpExpose.Mcptool {resource}_{verb}MapXiHanMcp()(POST /mcp);命令行宿主的 mcp 命令(stdio)
Protocols.CloudEvents事件类型能映射到主题事件类型 → 主题MapXiHanCloudEvents()(POST /events);CloudEventsWebhookPublisher

samples/SimpleApp 同时装配了四个适配器:orders.create 在 HTTP 上是 POST /orders,在 gRPC 上是 Orders/Create,在 MCP 上是 orders_create。

gRPC ​

启用 ​

  1. 引用 XiHan.Framework.Protocols.Grpc,UseFeature<GrpcProtocolFeature>()。
  2. 给 gRPC 一个 HTTP/2 端点。不用 TLS 时 Kestrel 不能在同一个端口上同时协商 HTTP/1.1 与 HTTP/2,示例单开 5001 端口跑 h2c。
  3. UseXiHan() 之后调用 MapXiHanGrpc(),需要给客户端发 .proto 时再调用 MapXiHanProto()。
json
{
  "Kestrel": {
    "Endpoints": {
      "Http": { "Url": "http://localhost:5000" },
      "Grpc": { "Url": "http://localhost:5001", "Protocols": "Http2" }
    }
  }
}
csharp
using XiHan.Framework.Protocols.Grpc;
using XiHan.Framework.Protocols.Grpc.Services;

// 接「HTTP 与 OpenAPI」页「启用」一节的 Program.cs:AddXiHan 里再加 .UseFeature<GrpcProtocolFeature>()
app.MapXiHanGrpc();     // /Orders/Create、/Orders/List
app.MapXiHanProto();    // GET /operations.proto

服务端不需要编译 .proto,也不依赖 Google.Protobuf:方法经 Grpc.AspNetCore.Server 的 IServiceMethodProvider 动态绑定,消息以字节传递,适配器按 Schema 在 protobuf 与 JSON 之间转换。只支持一元方法,流式方法与 gRPC 反射服务未提供;客户端用 /operations.proto 生成桩代码,或交给 grpcurl:

bash
curl -s localhost:5000/operations.proto -o operations.proto
grpcurl -plaintext -proto operations.proto -H "authorization: ApiKey dev-alice-key" localhost:5001 Orders/List

方法名 ​

服务名是资源段的 PascalCase(连字符分段,order-lines → OrderLines),方法名是动词的 PascalCase。配置节 XiHan:Protocols:Grpc:

键类型缺省说明
Packagestring空protobuf 包名,小写标识符以点分隔(如 acme.orders);写进 .proto 的 package,也是方法路径的前缀 /{package}.{Service}/{Method}
Methods:<操作 ID>string—Service/Method,显式覆盖推导的名字

服务名与方法名匹配 ^[A-Za-z_][A-Za-z0-9_]*$。以下情形在映射时失败,应用启动不起来:

情形错误码
推导或配置的服务 / 方法名不合法PROTOCOLS_VALIDATION_007
两个操作得到同一个服务 / 方法名PROTOCOLS_CONFLICT_004
暴露给 gRPC 的操作没有 Schema,或 Schema 映射不成 protobuf(嵌套数组、映射里的映射)PROTOCOLS_VALIDATION_006
同名消息在不同操作里形状不同PROTOCOLS_CONFLICT_003

由 JSON Schema 推出线格式 ​

请求与响应各对应一个消息,消息名取 Schema 的 title(封闭泛型带实参名,如 PageResultOfUserResponse,ADR-0034),$defs 里的每个对象定义一个消息。字段号按 properties 的声明顺序从 1 起;.proto 里的字段名是属性名的 lower_snake_case,推导的 json_name 与属性名不同时显式标注。

JSON Schemaprotobuf
string;contentEncoding: base64string;bytes
booleanbool
integer + format: int32 / int64int32 / int64
integer 无格式string,十进制文本
number + format: float / doublefloat / double
number 其他(decimal、无格式)string,十进制文本
arrayrepeated T,元素只能是标量或消息
object + additionalPropertiesmap<string, T>;键总是 string(JSON 属性名的文本),Dictionary<int, T> 也一样,propertyNames 不影响映射
$ref 到对象定义嵌套消息,允许递归
$ref 到字符串枚举string,不建 proto enum,线格式不变
可空(type 或 anyOf 含 null)optional
无约束({})string,JSON 文本原样;作数组元素或映射值时同样(JsonArray → repeated string,JsonObject → map<string, string>)

解码时,线上缺席的布尔、数值、repeated 与 map 字段按 proto3 默认值补进 JSON(零值、[]、{}),契约 Schema 的 required 因此在 gRPC 上成立;字符串、字节与嵌套消息缺席即缺席。samples/SimpleApp 的 /operations.proto:

protobuf
// Generated by XiHan Framework from operation contracts; the server derives it from the same schemas at runtime.
syntax = "proto3";

message ListOrdersRequest {
}

message OrderList {
  repeated OrderResponse items = 1;
}

message OrderResponse {
  string id = 1;
  string name = 2;
  int32 amount = 3;
  string created_at = 4;
}

message PlaceOrderRequest {
  string name = 1;
  int32 amount = 2;
}

service Orders {
  // orders.create
  rpc Create (PlaceOrderRequest) returns (OrderResponse);
  // orders.list
  rpc List (ListOrdersRequest) returns (OrderList);
}

上下文、状态与错误 ​

gRPC 元数据就是 HTTP/2 头,上下文映射与 HTTP 相同:traceparent、baggage、authorization、accept-language、xihan-deadline;没有 xihan-deadline 时接受 gRPC 标准的 grpc-timeout。gRPC 方法带 PipelineBypassMetadata,不经 UseXiHan() 的外层调用:每次调用在处理器里以请求头建自己的上下文、经完整管道执行,调用来源记 grpc 与方法全名(如 /Orders/Create),远端地址与 User-Agent 沿用 HTTP/2 请求。

错误 KINDgRPC 状态
VALIDATIONInvalidArgument
NOTFOUNDNotFound
CONFLICTAborted
DENIEDPermissionDenied;SECURITY_DENIED_001 与 SECURITY_DENIED_003 为 Unauthenticated(ADR-0042)
UNAVAILABLEUnavailable
TIMEOUTDeadlineExceeded
CANCELLEDCancelled
其他Internal

grpc-message 是按 accept-language 翻译的文案;尾部元数据 xihan-error-code 是框架错误码,xihan-trace-id 是 trace-id,成功时也有。适配器自己的失败:请求不是合法 protobuf 或与 Schema 不符为 InvalidArgument + PROTOCOLS_VALIDATION_005;响应编码不成 protobuf 为 Internal + PROTOCOLS_UNEXPECTED_001;未登记的方法为 Unimplemented(gRPC 服务端默认);应用未就绪(启动中或正在停止)为 Unavailable + KERNEL_UNAVAILABLE_001,不进管道,但与其他失败一样带两个尾部元数据,文案按 accept-language 翻译。

MCP ​

启用 ​

  1. 引用 XiHan.Framework.Protocols.Mcp,UseFeature<McpProtocolFeature>()。
  2. UseXiHan() 之后调用 MapXiHanMcp(),pattern 缺省 /mcp。
  3. 在消息目录里给要暴露的操作写 <操作 ID>.title 与 <操作 ID>.description,大模型据此选择 tool。
csharp
using XiHan.Framework.Protocols.Mcp;
using XiHan.Framework.Protocols.Mcp.Transports;

// 接「HTTP 与 OpenAPI」页「启用」一节的 Program.cs:AddXiHan 里再加 .UseFeature<McpProtocolFeature>()
app.MapXiHanMcp();      // POST /mcp:initialize、ping、tools/list、tools/call
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."
}

上面是 samples/SimpleApp/Localization/en-US.json 的内容,其余九种语言各一个文件。description 里不写授权要求,由协议适配器统一呈现。

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 的字段:

字段来源
nametool 名
title / description消息目录 <操作 ID>.title / <操作 ID>.description(- 换 _),按调用方区域翻译;没有就不给
inputSchema请求 Schema;Schema 的 type 不是 object 或操作没有 Schema 时为 {"type":"object"}
outputSchema响应 Schema,仅当其 type 是 object
annotations按契约推导,见下表
契约readOnlyHintdestructiveHintidempotentHint
只读(get、list,或 ReadOnly = true 的非标准动词)truefalsetrue
createfalsefalsefalse
updatefalsefalsetrue
deletefalsetruetrue
其他动词falsetruefalse

只读必须在 [Operation] 上显式声明(ADR-0041),没有声明的查询类自定义动词按 MCP 默认标成可能破坏,客户端会要求用户确认。

tools/call ​

arguments(缺省 {},必须是对象)就是操作的载荷。每个 tools/call 是一次独立调用:以承载请求的头部建新的调用上下文,在自己的服务作用域里经完整管道执行,工作单元与作用域服务不与外层 HTTP 请求共享;调用来源记 mcp 与 tool 名。成功时 content[0].text 是响应 JSON,响应是对象时同时给 structuredContent。操作失败(校验、未授权、冲突)不是协议错误,而是 isError: true 的结果,文本是 <错误码>: <文案>,文案按调用方区域翻译。

bash
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}}}'

bob 没有 orders.write,第二个请求的答复:

json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [{ "type": "text", "text": "SECURITY_DENIED_004: Permission 'orders.write' is missing." }],
    "isError": true
  }
}

传输与 JSON-RPC 错误 ​

MCP 协议版本 2025-06-18,消息为 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 的管道里判定。

情形error.codeerror.data.codeHTTP
请求体不是 JSON-32700PROTOCOLS_VALIDATION_001400
不是 JSON-RPC 2.0 请求(非对象、jsonrpc 不是 2.0、缺 method、id 为 null、params 不是对象)-32600PROTOCOLS_VALIDATION_001400
MCP-Protocol-Version 不受支持-32600PROTOCOLS_VALIDATION_002400
方法不受支持-32601PROTOCOLS_NOTFOUND_001200
tools/call 缺 name-32602PROTOCOLS_VALIDATION_001200
tool 未登记-32602PROTOCOLS_NOTFOUND_002200
arguments 不是对象-32602PROTOCOLS_VALIDATION_003200

stdio 传输 ​

命令行宿主里同一个服务端经 stdio 提供:装配了 McpProtocolFeature 与 AddXiHanCommandLine() 后运行 mcp 命令,stdin 与 stdout 每行一条 JSON-RPC 消息,日志只写 stderr,MCP 客户端以子进程方式连接。stdin 的字节按 UTF-8 解码,与控制台代码页无关(Windows 上重定向的标准输入不按 936、437 等代码页解码,中文参数不会成乱码);答复里的非 ASCII 字符按 JSON 转义成 \uXXXX,只含 ASCII,按任何控制台输出代码页写出都是合法的 UTF-8。

bash
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | dotnet run --project framework/samples/SimpleCli -- mcp

mcp 命令接受与 invoke 相同的上下文选项,作用于整个会话里的每个 tools/call:凭据 --authorization '<scheme> <value>' 或环境变量 XIHAN_AUTHORIZATION(选项优先,MCP 规范要求 stdio 服务端从运行环境取凭据),--tenant、--correlation、--locale(tools/list 的标题与描述、失败文案按它翻译),--timeout <秒> 是每个 tools/call 的时限。每个 tools/call 经完整管道,凭据在这次调用的 Authenticate 阶段认证;stdio 没有外层认证,凭据无效时需要认证的 tool 得到 isError(SECURITY_DENIED_003),会话继续。选项不合法(凭据不是 <scheme> <value>、时限不是正数)时不进入会话,stderr 写一行用法错误,退出码 64。调用来源只有协议 mcp 与 tool 名。宿主的写法见命令行宿主。

选项 ​

配置节 XiHan:Protocols:Mcp:

键类型缺省说明
ServerNamestring宿主的应用名initialize 答复的 serverInfo.name
ServerVersionstring框架版本serverInfo.version
Instructionsstring空initialize 答复的 instructions,空时不写
Tools:<操作 ID>string—显式 tool 名

initialize 总是答复本服务端的协议版本与 capabilities.tools.listChanged = false,由客户端决定是否继续;ping 答复 {}。

CloudEvents ​

入站 ​

  1. 引用 XiHan.Framework.Protocols.CloudEvents,UseFeature<CloudEventsProtocolFeature>()。它依赖 messaging.queue@1,Profiles.Standard 的内存消息实现即可满足。
  2. 用 AddMessageHandler<THandler>(topic) 订阅主题,写法见消息与事件。
  3. UseXiHan() 之后调用 MapXiHanCloudEvents(),pattern 缺省 /events。
csharp
using XiHan.Framework.Protocols.CloudEvents;
using XiHan.Framework.Protocols.CloudEvents.Inbound;

// 接「HTTP 与 OpenAPI」页「启用」一节的 Program.cs:AddXiHan 里再加 .UseFeature<CloudEventsProtocolFeature>()
app.MapXiHanCloudEvents();   // POST /events

入站端点经过 UseXiHan(),请求凭据在外层认证。框架不给它加任何要求,权限要由宿主自己加:

csharp
using XiHan.Framework.Hosting.AspNetCore.Endpoints;

// 权限码由某个特性用 FeatureContext.AddPermission 登记
app.MapXiHanCloudEvents().RequireXiHanPermission("events.deliver");

没有装配 security.authorization@1 时,这个要求只进入上下文、不被判定,端点等于敞开。

json
{
  "XiHan": {
    "Protocols": {
      "CloudEvents": {
        "Inbound": "Dispatch",
        "Topics": { "orders.placed": "orders" }
      }
    }
  }
}

三种内容模式按请求识别:

模式识别事件
structuredContent-Type: application/cloudevents+json请求体是一个结构化 JSON 事件
batchContent-Type: application/cloudevents-batch+json请求体是结构化事件的数组,按顺序处理
binary其他内容类型且有 ce-specversion 头ce-id、ce-source、ce-type 必填,ce-time(RFC 3339)、ce-subject 可选,其余 ce-* 头是扩展属性,头值按百分号解码;Content-Type 是数据内容类型,请求体是数据

事件类型先查 Topics:<事件类型>,没有映射时类型本身须满足主题文法(orders.placed 可以直接当主题)。扩展属性 xihantenant、xihanprincipal、xihancorrelation、xihanlocale 与 traceparent 在派发时进入调用上下文,成为租户、主体、关联 ID、区域与追踪。

扩展属性里的租户与主体是事件发送方给的,派发时不和请求的身份核对,能调这个端点的人就能选租户、写审计主体:与 HTTP 的 baggage 头不同,这是这个协议的设计,框架不收紧(ADR-0121)。权限也不按租户收窄,只授给平台的服务账号或服务角色,不要授给租户里的角色。

Inbound 决定事件的去向:

  • Dispatch(缺省):在请求内经 MessageDispatcher 派发给主题的处理器,以新的作用域与上下文经完整管道执行,处理器的结果就是响应:成功 202、无正文,失败按错误 KIND 写 Problem Details,发送方据此重试。
  • Publish:经 IMessagePublisher 发布到消息代理,成功即 202;至少一次投递、重试与死信由代理负责(含发件箱)。

batch 先把每个事件的类型解析成主题,任一事件没有映射时整批 400、一个都不处理;派发模式逐个处理,第一个失败即停止并作为响应;发布模式按主题分组各一次 PublishManyAsync,第一个失败的结果作为响应。已处理的事件不回滚,发送方重试整批时,处理器按 id + source 去重。

bash
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:5000/events \
  -H "Authorization: ApiKey dev-relay-key" \
  -H "ce-specversion: 1.0" -H "ce-id: evt-1" -H "ce-source: /external" -H "ce-type: orders.placed" \
  -H "ce-xihantenant: acme" -H "Content-Type: application/json" -d '{"id":"evt-1"}'

samples/SimpleApp 把 orders.placed 映射到主题 orders,事件交给 OrderPlacedHandler,返回 202。端点要求 events.deliver,只有服务账号 relay(dev-relay-key,不属于任何租户)持有:匿名 401,alice、bob 403。

情形状态码错误码
不是 CloudEvent(内容类型既不是 structured / batch,也没有 ce-specversion)400PROTOCOLS_VALIDATION_008
信封不合法(规范版本、必填属性、时间、扩展名)400MESSAGING_VALIDATION_002
事件类型没有映射,类型本身也不是合法主题400PROTOCOLS_VALIDATION_009
派发模式下主题没有处理器404PROTOCOLS_NOTFOUND_003
处理器或发布失败按错误 KIND处理器或代理返回的错误码

出站 Webhook ​

CloudEventsWebhookPublisher 实现 IMessagePublisher,把信封 POST 到 Webhooks:<主题> 配置的地址。它只按具体类型登记,不替换消息代理的发布者,要推送的地方显式注入它。信封按原样发出,不从当前调用补齐扩展(内部发布者才补齐):这是定下的规则(ADR-0074),xihantenant、xihanprincipal、xihancorrelation、xihanlocale 与 traceparent 都不由框架加上。接收方是第三方,租户、主体、关联是本系统的内部标识,推出去会暴露内部结构与身份,并让第三方依赖本该可变的内部 ID;追踪链要不要跨出组织边界也由调用方决定。要带给接收方的扩展显式写进信封。HTTP 层同理(ADR-0083):启用了追踪时 .NET 的 HttpClient 本会按当前 Activity 在请求头里写 W3C 的 traceparent、tracestate 与 baggage(与信封扩展无关),Webhook 的命名客户端 xihan.protocols.cloudevents 缺省不写;确实要把追踪链接到接收方,设 XiHan:Protocols:CloudEvents:PropagateTraceContext 为 true。它只作用于 Webhook 的客户端,不改全局传播,出站请求的客户端跨度照常记录。要调这个客户端的连接参数,用 AddHttpClient("xihan.protocols.cloudevents").UseSocketsHttpHandler((handler, _) => …) 配置同一个处理器,缺省保留;用 ConfigurePrimaryHttpMessageHandler 整个换掉主处理器时,是否传播由换上的处理器决定。下例把 orders 主题上的每条消息转发给外部服务,Webhook 失败时处理器返回失败,消息按消息子系统的规则重试,用尽后进死信:

csharp
using XiHan.Framework.Messaging.Abstractions.Handlers;
using XiHan.Framework.Primitives;
using XiHan.Framework.Protocols.CloudEvents.Outbound;

public sealed class OrderWebhookForwarder(CloudEventsWebhookPublisher webhooks) : IMessageHandler
{
    public ValueTask<Result> HandleAsync(MessageDelivery delivery, InvocationContext context, CancellationToken cancellationToken)
        => webhooks.PublishAsync(delivery.Topic, delivery.Envelope, cancellationToken);
}
csharp
using XiHan.Framework.Messaging.Abstractions;
using XiHan.Framework.Protocols.CloudEvents.Outbound;

builder.Services.AddMessageHandler<OrderWebhookForwarder>("orders");
// 命名 HttpClient:超时与重试按 IHttpClientFactory 的方式配置
builder.Services.AddHttpClient(CloudEventsWebhookPublisher.HttpClientName, client => client.Timeout = TimeSpan.FromSeconds(10));
json
{
  "XiHan": {
    "Protocols": {
      "CloudEvents": {
        "Outbound": "Binary",
        "Webhooks": { "orders": "https://hooks.example.com/orders" }
      }
    }
  }
}

Outbound 为 Binary(缺省,ce-* 头加数据体,头值按规范百分号编码)或 Structured(application/cloudevents+json 请求体),不接受 Batch。PublishManyAsync 一条时沿用这个模式,多条时用 batch 内容模式一次 POST,接收方须支持 application/cloudevents-batch+json,一个响应对整批生效。Webhook 地址必须是绝对的 http / https 地址,Inbound、Outbound 的值不区分大小写,不合法时读取配置就抛出 ArgumentException。

情形错误码
主题没有配置 WebhookPROTOCOLS_NOTFOUND_004
Webhook 返回非 2xx(details.status 是状态码)PROTOCOLS_UNAVAILABLE_001
连接失败PROTOCOLS_UNAVAILABLE_002,reason 只写异常类型的全名,完整异常进日志(ADR-0115)
地址被出站地址策略拒绝PROTOCOLS_UNAVAILABLE_002,reason 是 OutboundAddressDeniedException 的类型全名,不发起连接

Webhook 的接收方是第三方,它的 3xx 能把请求引到内网,所以这个命名客户端的连接与重定向都过出站地址策略。接收方在内网时,把它写进 XiHan:Security:Outbound:AllowedHosts。

发布者自己不重试,重试由调用方决定:像上例那样放在消息处理器里,或经发件箱投递,得到至少一次语义。

相关 ​

Released under The MIT License