核心摘要
安全的 Skill Runtime 有三个相互独立的层次:Skill Package 描述工作流,MCP Skills Extension 发现并传输文件,Eino Runtime 通过受控 Tool 或 Agent 执行已批准行为。不能把三者合并成一个信任对象。
Runtime 必须保留 MCP Server Identity 与 Skill URI 的二元身份,在执行前按持有的 Manifest 校验内容,获得用户同意,并在每次 Tool Call 时重新授权。Eino v0.9.21 为执行层提供 tool.InvokableTool、tool/utils.InferTool、ChatModelAgent、AgentTool、Runner 与 Callback 边界。
目录
- 三个契约而不是一个
- MCP Skills 扩展标准化了什么
- 安全加载生命周期
- 把 MCP Tool 映射到 Eino
- 可运行的策略防护 Tool
- 构建请求级 Agent
- 嵌套 Skill 与 AgentTool
- 可观测性与审计
- 测试策略
- 常见问题
- 一手资料
三个契约而不是一个
如果把指令、传输元数据和权限当成同一个可信文档,Skill Runtime 就会产生危险的权限混淆。
| 层次 | 职责 | 不能决定 |
|---|---|---|
| Agent Skills Format | 目录布局、SKILL.md、Frontmatter、配套文件 |
网络传输或用户授权 |
| MCP Skills Extension | Discovery、Resource URI、Manifest、Digest、文件读取 | Skill 是否可以运行、Tool Call 是否允许 |
| Eino Runtime | Agent/Tool 执行、Callback、Cancellation、Policy Adapter | 上游没有认证的身份声明 |
Agent Skills 规范要求 Skill 是一个至少包含 SKILL.md 的目录,Frontmatter 至少有 name 与 description。它推荐渐进披露:启动时只加载 Metadata,激活时加载指令,真正需要时再读取 Supporting Resource。
可选的 allowed-tools Frontmatter 字段仍是实验能力,而且 Host 支持情况不同。即使字段存在,也只能把它视为 Skill 请求的上限。有效权限应取交集:
effective tools =
tools requested by the Skill
intersect host policy
intersect authenticated user grants
intersect tenant and environment policy
CloudWeGo 的 Eino Quickstart 还展示了由本地 SKILL.md 文件提供内容、并挂到 DeepAgent Handler 上的 Skill Middleware。那是 Eino Runtime Integration,不是 MCP Skills Wire Protocol。远程实现仍需要 MCP Client 发现 Extension、校验 Resource,再把批准的内容交给 Eino Middleware。
MCP Skills 扩展标准化了什么
MCP Skills Extension 的标识符是 io.modelcontextprotocol/skills,适用于 MCP Base Revision 2026-07-28 或更高版本。
支持该扩展的 Server:
- 在
server/discover中同时声明resources与 Skills Extension - 实现
skills/list - 实现
skills/get - 通过
resources/read提供每个文件 - 仅在
directoryRead: true时实现可选的resources/directory/read
每个 Skill Entry 包含:
SKILL.md的 URI- 原样返回的 Frontmatter
- 完整文件 Manifest,包括 SHA-256 Digest 与 Size;动态内容则返回
"dynamic"
推荐 URI 形式是 skill://<skill-path>/SKILL.md,但 URI Scheme 本身不构成身份。跨 Server 时,Skill 身份必须表示为:
(host identity for the originating MCP server, Skill URI)
两个 Server 可以同时暴露 skill://refunds/SKILL.md,它们是两个无关 Skill。只用 URI 作为 Cache Key 或 Approval Key,会产生替换攻击风险。
安全加载生命周期
Skill 激活应该是状态机,而不是“下载 Markdown 后直接执行”。
在 Acting Window 内持有 Entry
Runtime 应保留批准时的精确 Skill Entry。执行期间读取的每个文件都要与该 Entry 的 Digest 和 Size 一致。Manifest 变化时,应停止并请求新的批准,不能静默接受新指令。
Digest 是完整性校验,不是 Trust Anchor。信任来自已认证的 Server Origin、组织策略、可用时的签名或 Provenance,以及明确审批。
按需加载
不能把所有配套文件一次性注入模型上下文。先读取批准的 SKILL.md,需要时再解析引用,并限制总文件数、字节数与深度。Relative Path 在标准化后再验证,拒绝 Path Traversal;所有本地物化路径都必须包含 Server Identity Namespace。
把嵌套 Skill 当成新 Principal
MCP Extension 允许 Skill 目录嵌套,但嵌套 Skill 被激活前需要新的用户同意。父 Skill 的批准不会授权子 Skill Frontmatter 或其请求的 Tool。
把 MCP Tool 映射到 Eino
MCP Tool Discovery 与 Eino Tool Execution 概念相似,但契约不同。
| MCP 表面 | Eino 表面 | Adapter 职责 |
|---|---|---|
| Tool Name 与 Description | schema.ToolInfo |
按 Server Identity 处理重名 |
inputSchema |
ParamsOneOf 或推导的 Go Struct |
保留 Schema 并校验参数 |
tools/call |
tool.InvokableRun |
添加逐请求 MCP Metadata 并处理 Transport |
| Structured/Text Content | String 或 schema.ToolResult |
校验 Output Schema 并限制内容 |
| Authorization Response | Go Error 或类型化 Result | 区分 Retry、Denial 与用户动作 |
MCP 2026-07-28 Tool 规范要求每个请求都在 _meta 中携带 Protocol Version、Client Info 与 Client Capabilities。这些传输职责应该由 Eino Adapter 下层的 MCP Client 实现。
Tool Annotation 来自不可信 Server 时只能视为 Hint;即使 Server 可信,也不能替代策略。一个被描述为 Read-only 的 Tool 仍可能实现错误。
可运行的策略防护 Tool
下面的程序把一个最小 MCP Client Interface 包装成类型化 Eino InvokableTool。数据返回给 Agent 前,会强制检查请求身份、Tool Grant、Tenant Ownership、Timeout、Output Size 与响应解码。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
)
type identityKey struct{}
type Identity struct {
Tenant string
Grants map[string]bool
}
type MCPClient interface {
CallTool(
ctx context.Context,
name string,
arguments json.RawMessage,
) (json.RawMessage, error)
}
type OrderInput struct {
OrderID string `json:"order_id" jsonschema:"required,description=tenant-scoped order identifier"`
}
type OrderOutput struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
}
type fakeMCPClient struct{}
func (fakeMCPClient) CallTool(
ctx context.Context,
name string,
arguments json.RawMessage,
) (json.RawMessage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if name != "orders.get" {
return nil, errors.New("unsupported tool")
}
var input OrderInput
if err := json.Unmarshal(arguments, &input); err != nil {
return nil, err
}
return json.Marshal(OrderOutput{
OrderID: input.OrderID,
Status: "paid",
})
}
func newOrderTool(client MCPClient) (tool.InvokableTool, error) {
return toolutils.InferTool(
"orders.get",
"Read one order that belongs to the authenticated tenant.",
func(ctx context.Context, input OrderInput) (OrderOutput, error) {
identity, ok := ctx.Value(identityKey{}).(Identity)
if !ok {
return OrderOutput{}, errors.New("missing identity")
}
if !identity.Grants["orders.get"] {
return OrderOutput{}, errors.New("permission denied")
}
if !strings.HasPrefix(input.OrderID, identity.Tenant+"-") {
return OrderOutput{}, errors.New("cross-tenant order denied")
}
callCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
arguments, err := json.Marshal(input)
if err != nil {
return OrderOutput{}, err
}
raw, err := client.CallTool(callCtx, "orders.get", arguments)
if err != nil {
return OrderOutput{}, fmt.Errorf("MCP orders.get: %w", err)
}
if len(raw) > 16*1024 {
return OrderOutput{}, errors.New("MCP result exceeds 16 KiB")
}
var output OrderOutput
if err := json.Unmarshal(raw, &output); err != nil {
return OrderOutput{}, fmt.Errorf("decode MCP result: %w", err)
}
return output, nil
},
)
}
func main() {
orderTool, err := newOrderTool(fakeMCPClient{})
if err != nil {
log.Fatal(err)
}
ctx := context.WithValue(context.Background(), identityKey{}, Identity{
Tenant: "acme",
Grants: map[string]bool{"orders.get": true},
})
result, err := orderTool.InvokableRun(
ctx,
`{"order_id":"acme-42"}`,
)
if err != nil {
log.Fatal(err)
}
fmt.Println(result)
// Output: {"order_id":"acme-42","status":"paid"}
}
模块依赖固定为:
require github.com/cloudwego/eino v0.9.21
Fake Client 让示例保持离线且可复现。真实实现还必须加入 MCP Version Negotiation 或 Discovery、逐请求 _meta、Transport Framing、Authorization、resultType 处理、Structured Content 校验与 Cancellation。
构建请求级 Agent
复用不可变 Base Model Client,再为每次请求构建有效 Tool Set。
构建流程如下:
- 认证调用方并解析 Tenant Policy。
- 加载并校验已批准的 Skill Entry 与所需 Resource。
- 从 Skill 指令或 Frontmatter 解析请求的 Tool。
- 与 Host Policy 和 User Policy 取交集。
- 从允许的 Server 获取当前 MCP Tool Descriptor。
- 对跨 Server 重名 Tool 添加 Namespace。
- 用上文 Guard 包装每次调用。
- 创建带有限
MaxIterations的adk.ChatModelAgent。 - 通过
adk.NewRunner与adk.WithCallbacks执行。
存在 Tool 时,Eino ChatModelAgent 会通过 model.WithTools 传递定义;配置的 Model 必须支持该 Option。不要在共享模型上使用已经 Deprecated 的可变 BindTools 模式,否则并发请求可能互相覆盖 Tool Set。
请求级构造不等于每次都建立新网络连接。Transport Client 可以单独复用,但已经授权的有效 Tool List 不能跨用户缓存,除非 Cache Key 包含所有策略维度。
嵌套 Skill 与 AgentTool
嵌套 Skill 加载与嵌套 Agent 执行是两种不同操作。
- Nested Skill 是另一份指令包,需要新的批准。
adk.NewAgentTool把 Eino Agent 包装成可委派 Tool。- 两种操作都不会授予新权限。
对子执行至少限制:
- 允许的 Child Skill Identity
- 最大嵌套深度和 Child 总数
- 共享的 Model、Tool、Token、Time 与 Output Budget
- 基于完整 Origin + URI Identity 的 Cycle Detection
- 显式 Context Contract,而不是无约束复制 Transcript
- 副作用操作的 Checkpoint 与 Idempotency Rule
Eino 会把 Child AgentTool 的 Exit、Transfer 和 BreakLoop 限制在 Tool Boundary 内,而 Interrupt 可以向上传播以支持 Resume。这保护的是 Orchestration Control Flow,应用授权仍由 Runtime 负责。
可观测性与审计
一条逻辑执行应贯通 Skill Loading、Eino 与 MCP:
skill.run
skill.verify
agent.run
model.call
eino.tool
policy.evaluate
mcp.tools.call
output.validate
建议记录:
- Server Identity 与 Skill URI
- Held Manifest Digest 或 Dynamic-manifest 状态
- Policy Version 与 Approval Reference
- Eino、MCP、Model 与 Prompt Version
- Namespaced Tool Identity 与 Side-effect Class
- Decision、Duration、Retry 与有界 Size Metric
- Terminal Result Class
默认不要记录原始 Skill 文件、Prompt、Credential 或 Tool Result。审计记录应证明哪个 Policy 和 Artifact Version 执行了操作,而不是复制敏感内容。
测试策略
契约测试
- Frontmatter Name 与 Skill URI Name 一致
- 文件 Digest 和 Size 与持有 Entry 匹配
- 拒绝 Path Traversal 与跨 Server 同名替换
- Dynamic Manifest 遵循单独审批策略
- 未协商的 Extension Method 永远不会被调用
Tool Adapter 测试
- 允许调用成功
- 缺少 Identity 或 Grant 时 Fail Closed
- 跨 Tenant 对象被拒绝
- 非法 JSON 与 Output Schema 被拒绝
- Timeout 与 Cancellation 正确传播
- 超大结果被拒绝
- 副作用重试必须提供 Idempotency Key
Agent 测试
- 只向模型暴露 Effective Tool
- 模型猜中名字也不能调用 Denied Tool
- 不同 Origin 的同名 Tool 仍可区分
- 嵌套调用共享全局预算
- 恶意 Skill 指令无法覆盖 Policy
- 每个结果都归因到正确 Skill 与 Server Version
常见问题
应该把完整 Skill 放进 System Prompt 吗?
不应该。先加载已批准的 SKILL.md,需要时再读取配套 Resource。Policy 与可信 Runtime Context 必须和不可信指令分开。
Skill 批准后,MCP Server 可以修改 Tool 吗?
可以,Tool List 可能变化。应根据协议 Cache Hint 获取或刷新 Descriptor,但每次都要重新应用 Effective Policy。新 Descriptor 不能仅因名字与旧 Tool 相同就继承批准。
Skill 应该缓存吗?
Cache Key 必须包含 Originating Server Identity、Skill URI 与 Held Manifest。按 Cache Freshness 更新,读取时校验字节;Identity 或内容超出已批准策略时,Approval 必须失效。
模型可以自己批准 Tool Call 吗?
不可以。模型只能提出调用。Host、用户交互层与 Policy Engine 决定 Tool 是否暴露及调用是否执行。
总结
Eino 与 MCP 解决互补问题:MCP 传输 Skill 与 Tool,Eino 执行受控 Tool 和 Agent 边界。生产 Runtime 必须保留 Origin、校验批准的 Artifact、渐进加载内容、根据可信策略计算权限,并在每次调用时执行限制。指令属于数据,授权必须写在代码中。