MCP Adapter 输入输出 Schema 设计实战:对象格式 vs 扁平化格式怎么选?
MCP Adapter 输入输出 Schema 设计实战:对象格式 vs 扁平化格式怎么选?
MCP Adapter 是 WordPress 官方的 MCP 集成包,它把 WordPress Abilities 桥接成 AI 客户端可调用的 MCP 工具、资源与提示词。其中输入输出 Schema 设计是最容易踩坑的一环:MCP 协议强制工具 schema 为 object 类型,而 WordPress 允许你写"扁平化"的单值 schema。本文带你彻底搞懂对象格式 vs 扁平化格式怎么选,以及 adapter 背后的自动包装机制。
🧭 先搞懂前提:MCP 为什么"只认" object?
MCP 规范要求工具的 inputSchema 必须是 type: object——AI 客户端发起 tools/call 时,arguments 永远是一个 JSON 对象,不存在"裸传一个字符串"这种调用方式。
而 WordPress Abilities API 的 input_schema 比 MCP 更宽松:一个工具只需要接收一个字符串时,你可以直接把 schema 写成 {'type': 'string', ...},不必套 object。这就是扁平化格式(flattened schema)的由来。
MCP Adapter 的定位就是抹平这两层差异:你写哪种格式都能用,adapter 在 wire 层(MCP 客户端看到的)和执行层(你的回调收到的)之间自动转换。
📐 两种格式到底差在哪?
以一个"按文章类型计数"的工具为例,同一份能力的两种写法:
对象格式(Object Schema)——标准的 JSON Schema 对象:
{
"type": "object",
"properties": {
"post_type": { "type": "string", "description": "要统计的文章类型" }
},
"required": ["post_type"]
}
扁平化格式(Flattened Schema)——直接描述那一个值:
{
"type": "string",
"description": "要统计的文章类型",
"enum": ["post", "page", "attachment"]
}
区别一目了然:
| 维度 | 对象格式 | 扁平化格式 |
|---|---|---|
顶层 type |
必须是 object |
string / number / integer / boolean / array |
| 参数承载 | properties 下可放多个具名参数 |
只有一个值,没有参数名 |
required 语义 |
声明哪些字段必填 | 由 adapter 自动生成 |
| wire 层是否被改写 | 原样透传 | 被包装成 {type: object} 并生成包装键 |
| 适用场景 | 多参数、复杂校验、嵌套结构 | 单值入参 / 单值出参 |
💡 注意:扁平化 ≠ "省略写法",它表达的是"这个工具只有一个输入"。多参数场景请老老实实用对象格式。
⚙️ 自动包装如何运作?(3 个关键步骤)
1️⃣ Wire 层:SchemaTransformer 统一"套壳"
转换逻辑集中在 SchemaTransformer.php:
- schema 是
object→ 原样返回,不做任何改动; - schema 是
string/number/integer/boolean/array→ 被 wrap_in_object() 包成{'type': 'object', 'properties': {包装键: 原schema}, 'required': [包装键]}; - 原始的
description、enum、minimum、pattern等所有元数据完整保留在包装键内部。
包装键由 RegisterAbilityAsMcpTool 决定:
- input_schema → 包装键固定为
input - output_schema → 包装键固定为
result
也就是说,上面那个扁平化的 string schema,AI 客户端实际看到的就是:
{
"type": "object",
"properties": {
"input": { "type": "string", "description": "要统计的文章类型", "enum": ["post", "page", "attachment"] }
},
"required": ["input"]
}
2️⃣ 执行层:入参解包、出参包装
转换发生时,adapter 会在内部元数据里记一笔 input_schema_transformed / output_schema_transformed(见 RegisterAbilityAsMcpTool.php),供执行阶段使用:
- 入参解包:McpTool::unwrap_input_if_needed() 从客户端传来的
{input: 'post'}中取出'post',你的回调收到的就是裸值,而不是数组; - 出参包装:McpTool::wrap_output_if_needed() 把你返回的裸值包成
{result: 42}再交给 MCP 客户端。
一句话总结:回调世界里永远是解包后的值;wire 世界里永远是 object。
🎯 一张表拍板:什么时候用哪种?
| 场景 | 推荐格式 | 理由 |
|---|---|---|
| 多个参数(如标题 + 正文 + 状态) | ✅ 对象格式 | 每个参数有名字、有独立校验,AI 更容易填对 |
| 需要嵌套结构、数组 of object | ✅ 对象格式 | 扁平化装不下层级 |
| 单个字符串 / 数字 / 布尔 / 数组 | ✅ 扁平化格式 | 声明更短,约束(enum、min/max)仍全部保留 |
| 输出只有单个值(一个 ID、一个计数、一个 slug) | ✅ 扁平化格式 | 回调直接 return 42;,不用自己组数组 |
输出是多个相关字段(post_id + url + status) |
✅ 对象格式 | 字段需要自描述 |
决策口诀:默认对象,单值才扁平。 拿不准时选对象格式——它永远不会被 adapter 改写,行为最可预测(这也是官方文档推荐写法的原因,见 creating-abilities.md)。
💻 实战:一个扁平化工具的完整闭环
wp_register_ability( 'my-plugin/count-posts', [
'label' => 'Count Posts',
'category' => 'site',
'input_schema' => [
'type' => 'string',
'enum' => [ 'post', 'page', 'attachment' ],
],
'output_schema' => [
'type' => 'integer',
'description' => 'Total number of posts found',
],
'execute_callback' => function ( $input ) {
// $input 是解包后的裸字符串,例如 'post'
$count = wp_count_posts( $input );
return $count->publish; // 直接 return 42,不要包成 ['result' => 42]
},
'permission_callback' => function () {
return current_user_can( 'read' );
},
'meta' => [ 'public' => true ],
] );
一次调用的完整数据流:
- MCP 客户端发送:
{"name": "my-plugin-count-posts", "arguments": {"input": "post"}}—— 永远是 object; - adapter 解包:
{"input": "post"}→'post'; - 你的回调:
wp_count_posts('post')并return 42; - adapter 包装:
42→{"result": 42}; - MCP 客户端收到:
{"result": 42}。
同样的能力如果写成对象格式,execute_callback 收到的就是 $input<a href="https://link.gitcode.com/i/365c47cc7aecd8d924661764e9c6b237" target="_blank">'post_type'],客户端也传 {"post_type": "post"}——多一层具名参数,语义更明确,适合参数会变多的工具(完整对比见 [basic-examples.md 的 create-post 示例)。
🚧 常见坑与排错
坑 1:回调里多包了一层
扁平格式下返回 ['result' => 42] 是常见误操作。包装是 adapter 的职责,你返回裸值 42 即可;反过来,对象格式下返回什么,客户端就收到什么(无包装),别指望 adapter 帮你套壳。
坑 2:空参数 {} 传进来变成了 null 或 []?
这是 AbilityArgumentNormalizer 的有意设计:
- ability 没有 input_schema:
{}→null("无参数"); - ability 有 schema:
{}→[],这样空对象能通过 schema 校验,而不是报 "not of type object"; - schema 顶层声明了
default:{}和null都归一化为null,让 WordPress 去应用默认值。
坑 3:默认服务器路径 vs 自定义服务器路径
默认服务器的 mcp-adapter-execute-ability 元工具传的是 {"ability_name": "...", "parameters": {...}},parameters 按 ability 原始 schema 的形状传递(见 ExecuteAbilityAbility.php);而注册在自定义服务器上的工具,才由 adapter 执行 input/result 包装与解包。两条路径不要混着理解。
坑 4:schema 里没有 type 字段
SchemaTransformer 会补上 type: object 并原样透传,不会误伤——但建议显式写出 type,可读性更好。
✅ 5 条要点带走
- MCP 强制 object:客户端的
arguments永远是 JSON 对象,这是协议层约束; - 扁平化只是你的写法:wire 上会被自动包成
{input: ...}/{result: ...},且enum、minimum等约束原样保留; - 回调永远处理裸值:解包入参、包装出参都是 adapter 的活,别手动加壳;
- 决策默认值:多参数 / 嵌套 / 复杂校验 → 对象格式;单值输入或输出 → 扁平化格式;
- 空参数交给归一化器:
{}的 null /[]/ default 行为已有明确规则,不要在回调里写防御性兼容。
把 Schema 设计想成"契约分层":你与 WordPress 之间用裸值契约,MCP 客户端与 wire 之间用 object 契约,中间的桥就是 adapter。选对格式后,剩下的转换它全帮你干了。
