MCP 工具集成:外部工具变 Eino Tool

系列「企业级 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]*ParameterInfo
  • NewParamsOneOfByJSONSchema():完整 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 都是同一套代码。

下篇继续。


代码来源:cloudwego/eino-ext · cloudwego/eino-examples

相关推荐
小白鼠幻想家2 小时前
工具调用设计:Agent 的"手"为什么总是笨拙的
agent
沉默王二2 小时前
国产版Codex?阿里QoderWork有点东西,设计出来的Codex+Claude Code学习网站好看啊(附教程,超简单)
openai·agent·ai编程
Coffeeee2 小时前
Prompt要花心思写,与 AI 对话的七个技巧
人工智能·aigc·ai编程
lihaozecq2 小时前
继 Web Coding Agent 后,我做了一个本地优先的桌面 AI Agent
前端·agent
齐翊2 小时前
分享一个在 Claude Code 里 [同时] 用多个 ApiKey 的方法
程序员·github·agent
老梁agent2 小时前
工业 Agent 的边缘部署:Ollama + LangChain4j 本地推理方案
物联网·边缘计算·agent
刘棕霆3 小时前
27—AI Skill 测评如何避免确认偏误:盲测对比与解盲分析
aigc·ai编程·测试
Flynt3 小时前
配置Chrome DevTools MCP,我在Windows上折腾了两个晚上
ai编程·claude·mcp