系列「企业级 AI Agent 实现拆解」E25 篇。上一篇讲了中间件系统:在 Agent 执行流中插入自定义逻辑。这篇讲 MCP 工具集成------把遵守 MCP 协议的任意外部工具,直接变成 Eino Agent 能用的 Tool,以及 DeepFlux 在此基础上额外做了什么。
读完这篇你会知道
- MCP 是什么:JSON-RPC 2.0 打底,三种传输方式
- Eino 的 Tool 接口体系:
BaseTool/InvokableTool两层GetTools()的核心逻辑:30 行代码里发生了什么- Schema 转换:MCP 的
InputSchema怎么变成 Eino 的*jsonschema.Schema- 两套 MCP SDK 适配的区别:mark3labs vs 官方 SDK
ToolCallResultHandler:工具返回后的拦截钩子- DeepFlux 双向桥接:出方向把 KB/Memory 暴露给外部 MCP 客户端
一、MCP 协议是什么
MCP 全称 Model Context Protocol,规定了 AI 模型和外部工具之间的通信格式。
底层是 JSON-RPC 2.0------所有消息长一个样:
json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "calculate",
"arguments": { "operation": "add", "x": 3, "y": 5 }
}
}
你只需要关心三个方法:
| 方法 | 用途 |
|---|---|
initialize |
握手,交换协议版本和能力 |
tools/list |
查询 MCP Server 有哪些工具 |
tools/call |
执行某个工具 |
传输方式有三种:stdio(子进程管道)、SSE(HTTP 长连接)、Streamable HTTP。
对 Eino 来说,这些全部是外部的------Eino 只认识自己定义的 Tool 接口,不认识 MCP。所以需要一个适配层。
二、Eino 的 Tool 接口
Eino 把工具抽象为两层:
go
// 最小接口:只提供元数据(告诉 LLM 这个工具叫什么、有什么参数)
type BaseTool interface {
Info(ctx context.Context) (*schema.ToolInfo, error)
}
// 可调用工具:在 BaseTool 基础上增加执行能力
type InvokableTool interface {
BaseTool
InvokableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (string, error)
}
ToolInfo 是关键结构:
go
type ToolInfo struct {
Name string
Desc string
*ParamsOneOf // 参数 schema,支持两种形式
}
ParamsOneOf 有两种创建方式:
NewParamsOneOfByParams():简化写法,传map[string]*ParameterInfoNewParamsOneOfByJSONSchema():完整 JSON Schema,MCP 适配用这个
只要你的对象实现了 InvokableTool,Eino 的 ToolsNode 就能调它。目标很清晰:把 MCP 工具包一层,让它实现这个接口。
三、GetTools():30 行代码做了三件事
eino-ext 的核心函数在 components/tool/mcp/mcp.go:
go
func GetTools(ctx context.Context, conf *Config) ([]tool.BaseTool, error) {
// ① 向 MCP Server 查询工具列表
listResults, err := conf.Cli.ListTools(ctx, mcp.ListToolsRequest{})
ret := make([]tool.BaseTool, 0)
for _, t := range listResults.Tools {
// ② 把 MCP 的 InputSchema 转换成 Eino 的格式
marshaledInputSchema, _ := sonic.Marshal(t.InputSchema)
inputSchema := &jsonschema.Schema{}
sonic.Unmarshal(marshaledInputSchema, inputSchema)
// ③ 创建包装器,实现 InvokableTool 接口
ret = append(ret, &toolHelper{
cli: conf.Cli,
info: &schema.ToolInfo{
Name: t.Name,
Desc: t.Description,
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(inputSchema),
},
})
}
return ret, nil
}
第一步:查工具列表
conf.Cli.ListTools() 发出一条 JSON-RPC 请求 tools/list,得到所有工具的名称、描述和参数 schema。
第二步:Schema 转换
这是最容易出问题的地方。MCP SDK 里的 InputSchema 类型是 map[string]interface{}(动态类型),而 Eino 需要的是强类型的 *jsonschema.Schema。
转换方式:先 Marshal 成 JSON 字节,再 Unmarshal 成目标类型。看起来绕,实际上是最稳妥的做法------不依赖字段名映射,不受 struct tag 影响。
第三步:包装器
toolHelper 是私有结构体,持有 MCP 客户端引用。当 Eino 的 ToolsNode 需要执行工具时,调用它的 InvokableRun():
go
func (m *toolHelper) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
result, err := m.cli.CallTool(ctx, mcp.CallToolRequest{
Request: mcp.Request{Method: "tools/call"},
Params: mcp.CallToolParams{
Name: m.info.Name,
Arguments: json.RawMessage(argumentsInJSON), // 直接透传,不解析
},
})
// ...
return sonic.MarshalString(result)
}
参数是 Eino 传来的 JSON 字符串,直接作为 json.RawMessage 扔给 MCP,结果序列化成字符串返回。没有额外解析、没有类型转换------整条路径的数据就是 JSON,两端透传。
四、完整调用链路
javascript
用户输入 → LLM 决定调工具
↓
Eino ToolsNode(接收 ToolCall 消息:工具名 + JSON 参数)
↓
找到对应的 toolHelper
↓
InvokableRun(argumentsInJSON)
↓
MCP Client 发 JSON-RPC: tools/call
↓
MCP Server 执行工具逻辑
↓
JSON-RPC 响应
↓
序列化为字符串 → 返回给 LLM 作为 ToolMessage
Eino 看到的只是一个实现了接口的对象,MCP Server 看到的只是标准 JSON-RPC,中间层完全透明。
五、接入只需三步
go
// 1. 创建 MCP 客户端并握手
cli, _ := client.NewSSEMCPClient("http://your-mcp-server/sse")
cli.Start(ctx)
cli.Initialize(ctx, mcp.InitializeRequest{
Params: mcp.InitializeRequestParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{Name: "my-agent", Version: "1.0.0"},
},
})
// 2. 拉取工具并转换
import mcpTool "github.com/cloudwego/eino-ext/components/tool/mcp"
tools, _ := mcpTool.GetTools(ctx, &mcpTool.Config{Cli: cli})
// 3. 塞进 ToolsNode
toolsNode, _ := compose.NewToolNode(ctx, &compose.ToolsNodeConfig{Tools: tools})
此后 Agent 能透明地调用这个 MCP Server 上的所有工具,新增工具不需要改 Agent 代码------下次 GetTools() 自动发现。
六、两套 SDK,选哪个
eino-ext 同时提供两套实现:
| mark3labs 版本 | 官方 SDK 版本 | |
|---|---|---|
| 包路径 | components/tool/mcp |
components/tool/mcp/officialmcp |
| 底层 | github.com/mark3labs/mcp-go |
github.com/modelcontextprotocol/go-sdk |
| 运行时选项 | 支持(自定义 Header、Meta) | 不支持 |
| 分页 | 不支持 | 支持(Cursor 参数) |
两套核心逻辑几乎一样,区别在选项支持。mark3labs 版本多了一个 ToolCallResultHandler:
go
conf := &mcpTool.Config{
Cli: cli,
ToolCallResultHandler: func(ctx context.Context, name string, result *mcp.CallToolResult) (*mcp.CallToolResult, error) {
// 工具返回后、交给 LLM 前的拦截点
// 可以:截断超长结果、过滤敏感内容、记日志
return result, nil
},
}
对于会返回大量文本的工具(网页抓取、数据库查询),这个钩子可以在这里裁剪,避免单次工具输出把 context 撑爆。
七、DeepFlux 的双向桥接
eino-ext 的适配是单向的:外部 MCP → Eino Tool(入方向)。
DeepFlux 在此基础上做了出方向:把平台自己的知识库(KB)、长期记忆(Memory)、提示词模板,暴露给外部 MCP 客户端(Cursor、Cline 等)。
入方向(eino-ext 已解决):外部 MCP 工具 → Eino Tool → DeepFlux Agent 能用
出方向 (DeepFlux 新增,server/internal/mcp/bridge.go):
go
// 知识库 → MCP Resource(deepflux://kb/<namespace>)
type kbResources struct{ svc KBService }
// 知识库搜索 → MCP Tool(kb_search)
type kbSearchTool struct{ svc KBService }
func (k *kbSearchTool) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
// 解析参数,调 KB 服务,返回 top-K 结果
}
// 长期记忆 → 两个 MCP Tool
// memory_recall:召回
// memory_write:写入(高风险,接了 HITL 审批)
RegisterAll 把这些挂载到 MCP Server,同时配置 HITL:
go
func RegisterAll(srv *Server, kb KBService, mem MemoryService, opts BridgeOptions) {
srv.RegisterResources(&kbResources{svc: kb})
srv.RegisterTool(&kbSearchTool{svc: kb})
srv.RegisterTool(&memoryRecallTool{svc: mem})
srv.RegisterTool(&memoryWriteTool{svc: mem})
// memory_write 是高风险操作,触发人工审批
srv.SetHITL(func(ctx context.Context, tool string, input json.RawMessage) (bool, string) {
if highRisk[tool] && opts.HITL != nil {
return opts.HITL.Decide(ctx, tool, input)
}
return true, ""
})
}
两者对比:
| 维度 | eino-ext MCP 适配 | DeepFlux MCP 桥接 |
|---|---|---|
| 方向 | 单向(入) | 双向(入 + 出) |
| 职责 | 协议转换(通用) | 业务语义暴露(领域特定) |
| 暴露类型 | Tool | Tool + Resource + Prompt |
| 安全控制 | 无 | HITL 审批高风险工具 |
| 传输层 | SSE / stdio | stdio / SSE / Streamable HTTP |
eino-ext 解决"语言不通"(MCP 和 Eino 接口不兼容),DeepFlux 解决"门没开"(让外部工具能访问平台自己的数据)。
小结
MCP 协议不复杂:JSON-RPC 2.0 打底,三个方法(initialize / tools/list / tools/call),三种传输方式(stdio / SSE / Streamable HTTP)。
eino-ext 的适配核心是 GetTools():查工具列表 → Schema 类型转换(JSON 序列化绕一圈)→ 包装成 toolHelper 实现 Eino 接口。参数透传,结果透传,没有多余逻辑。
DeepFlux 在此基础上做了反向:把 KB 和 Memory 通过 MCP 协议暴露出去,同时在高风险写操作上接入 HITL 审批。
协议桥接的价值是生态共用:遵守 MCP 协议的工具,不管是谁提供的,接进 Eino 都是同一套代码。
下篇继续。