来源：https://framework.docs.xihanfun.com/guide/protocols

# gRPC、MCP 与 CloudEvents

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

## 一份契约，多个载体

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

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

| 适配器 | 暴露条件 | 映射 | 端点 |
| --- | --- | --- | --- |
| `Protocols.Grpc` | `Expose.Grpc`，且有 Schema | 服务 `{Resource}`、方法 `{Verb}` | `MapXiHanGrpc()`、`MapXiHanProto()`（`GET /operations.proto`） |
| `Protocols.Mcp` | `Expose.Mcp` | tool `{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`：

| 键 | 类型 | 缺省 | 说明 |
| --- | --- | --- | --- |
| `Package` | string | 空 | 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 Schema | protobuf |
| --- | --- |
| `string`；`contentEncoding: base64` | `string`；`bytes` |
| `boolean` | `bool` |
| `integer` + `format: int32` / `int64` | `int32` / `int64` |
| `integer` 无格式 | `string`，十进制文本 |
| `number` + `format: float` / `double` | `float` / `double` |
| `number` 其他（`decimal`、无格式） | `string`，十进制文本 |
| `array` | `repeated T`，元素只能是标量或消息 |
| `object` + `additionalProperties` | `map<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 请求。

| 错误 KIND | gRPC 状态 |
| --- | --- |
| `VALIDATION` | `InvalidArgument` |
| `NOTFOUND` | `NotFound` |
| `CONFLICT` | `Aborted` |
| `DENIED` | `PermissionDenied`；`SECURITY_DENIED_001` 与 `SECURITY_DENIED_003` 为 `Unauthenticated`（ADR-0042） |
| `UNAVAILABLE` | `Unavailable` |
| `TIMEOUT` | `DeadlineExceeded` |
| `CANCELLED` | `Cancelled` |
| 其他 | `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 的字段：

| 字段 | 来源 |
| --- | --- |
| `name` | tool 名 |
| `title` / `description` | 消息目录 `<操作 ID>.title` / `<操作 ID>.description`（`-` 换 `_`），按调用方区域翻译；没有就不给 |
| `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]` 上显式声明（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.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 传输

命令行宿主里同一个服务端经 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 名。宿主的写法见[命令行宿主](./command-line)。

### 选项

配置节 `XiHan:Protocols:Mcp`：

| 键 | 类型 | 缺省 | 说明 |
| --- | --- | --- | --- |
| `ServerName` | string | 宿主的应用名 | `initialize` 答复的 `serverInfo.name` |
| `ServerVersion` | string | 框架版本 | `serverInfo.version` |
| `Instructions` | string | 空 | `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)` 订阅主题，写法见[消息与事件](./messaging)。
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" }
      }
    }
  }
}
```

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

| 模式 | 识别 | 事件 |
| --- | --- | --- |
| structured | `Content-Type: application/cloudevents+json` | 请求体是一个结构化 JSON 事件 |
| batch | `Content-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`） | 400 | `PROTOCOLS_VALIDATION_008` |
| 信封不合法（规范版本、必填属性、时间、扩展名） | 400 | `MESSAGING_VALIDATION_002` |
| 事件类型没有映射，类型本身也不是合法主题 | 400 | `PROTOCOLS_VALIDATION_009` |
| 派发模式下主题没有处理器 | 404 | `PROTOCOLS_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`。

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

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

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

## 相关

- [Protocols.Grpc 包](../packages/protocols-grpc)、[Protocols.Mcp 包](../packages/protocols-mcp)、[Protocols.CloudEvents 包](../packages/protocols-cloudevents)
- [操作契约](./operations)、[HTTP 与 OpenAPI](./http)、[宿主与 Web 应用](./web)
- [命令行宿主](./command-line)：`mcp` 命令
- [消息与事件](./messaging)：主题、处理器、发件箱与 CloudEvents 信封
- 规范：`spec/protocol/grpc.md`《gRPC 载体》、`spec/protocol/mcp.md`《MCP 载体》、`spec/protocol/cloudevents.md`《CloudEvents 载体》、RFC-0011《操作契约与 XGI》、RFC-0009《消息、CloudEvents 信封与发件箱》
