核心摘要

安全的 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 边界。

目录

  1. 三个契约而不是一个
  2. MCP Skills 扩展标准化了什么
  3. 安全加载生命周期
  4. 把 MCP Tool 映射到 Eino
  5. 可运行的策略防护 Tool
  6. 构建请求级 Agent
  7. 嵌套 Skill 与 AgentTool
  8. 可观测性与审计
  9. 测试策略
  10. 常见问题
  11. 一手资料

三个契约而不是一个

如果把指令、传输元数据和权限当成同一个可信文档,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 请求的上限。有效权限应取交集:

text
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:

  1. 在 server/discover 中同时声明 resources 与 Skills Extension
  2. 实现 skills/list
  3. 实现 skills/get
  4. 通过 resources/read 提供每个文件
  5. 仅在 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 身份必须表示为:

text
(host identity for the originating MCP server, Skill URI)

两个 Server 可以同时暴露 skill://refunds/SKILL.md,它们是两个无关 Skill。只用 URI 作为 Cache Key 或 Approval Key,会产生替换攻击风险。

安全加载生命周期

Skill 激活应该是状态机,而不是“下载 Markdown 后直接执行”。

flowchart TD A["发现 Server Capability"] --> B["skills/list 或显式 URI"] B --> C["skills/get 持有 Entry"] C --> D["展示来源、用途与请求的 Tool"] D --> E{"用户与策略是否批准"} E -->|"否"| F["拒绝"] E -->|"是"| G["按需读取 Resource"] G --> H["根据持有 Digest 校验字节"] H --> I["编译有效运行时策略"] I --> J["使用受控 Tool 执行"] J --> K["审计结果与 Resource Version"]

在 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 与响应解码。

go
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"}
}

模块依赖固定为:

text
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。

构建流程如下:

  1. 认证调用方并解析 Tenant Policy。
  2. 加载并校验已批准的 Skill Entry 与所需 Resource。
  3. 从 Skill 指令或 Frontmatter 解析请求的 Tool。
  4. 与 Host Policy 和 User Policy 取交集。
  5. 从允许的 Server 获取当前 MCP Tool Descriptor。
  6. 对跨 Server 重名 Tool 添加 Namespace。
  7. 用上文 Guard 包装每次调用。
  8. 创建带有限 MaxIterations 的 adk.ChatModelAgent。
  9. 通过 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:

text
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、渐进加载内容、根据可信策略计算权限,并在每次调用时执行限制。指令属于数据,授权必须写在代码中。

相关资源

一手资料