Day 18 / 共 20 天 · 阶段5 工具与 Agent

插件与 MCP:Dify 的"能力生态"怎么热插拔

Day16-17 的工具、模型都是 Dify 内置的。但 Dify 不可能内置世界上所有能力。于是有了两条"往里加东西"的通道:插件(Plugin)——第三方把工具/模型/数据源打包,Dify 装上就能用,且跑在独立进程里;MCP——一个开放协议,让 Dify 能连接任何"说 MCP 的"外部工具服务器。今天进 core/plugin/core/mcp/,弄清:①插件为什么要单开一个 daemon 进程、Dify 怎么和它通信;②PluginService 怎么管理安装;③MCP 是什么协议、MCPClient 怎么连上并调用外部工具。

📍 你在 20 天里的位置(阶段5:工具与 Agent · D16-18)
D15 工作流收尾 D16 工具系统 D17 Agent D18 插件/MCP D19 任务队列/可观测 D20 收官全景
💡 先用两个类比兜住今天 类比一:插件跑在独立进程,像手机 App 之间的"沙盒隔离"。你装的第三方 App 崩了,不会把整个手机拖垮,也偷不到别的 App 的数据。Dify 让插件跑在单独的 plugin daemon 进程里,就是同样的道理——插件是别人写的代码,不能让它一崩溃就带垮整个 Dify、更不能让它乱翻主程序的内存。主程序和插件之间只通过一个"内部 API 窗口"隔空喊话。类比二:MCP 像USB 接口标准。以前每个外设一根专用线(每接一个工具写一套对接代码),有了 USB,任何设备只要"长了 USB 口"就能插上。MCP 就是给"AI 工具"定的这个统一接口——外部服务只要实现 MCP 协议,Dify 不改代码就能接进来用。
L01

痛点:能力怎么"热插拔"、还不拖垮主程序

🤔 痛点Dify 内置的工具/模型总是有限的。用户想加个"公司内部 CRM 工具"、想接个"小众模型供应商",总不能改 Dify 源码、重新发版吧?而且第三方代码有风险:它可能依赖冲突的库、可能内存泄漏、甚至可能是恶意的。怎么让能力像 App 一样"随装随用、坏了不连累别人、也偷不到主程序数据"?
💡 本质:进程隔离 + 协议标准化Dify 给出两招:①插件系统——第三方能力打包成插件,运行在独立的 plugin daemon 进程里,主程序通过一个"内部 API"和它通信(进程隔离,互不干扰);②MCP——采用业界开放协议 Model Context Protocol,让 Dify 作为客户端去连接任何 MCP 服务器(协议标准化,一次对接、通吃所有 MCP 工具)。前者管"自家插件市场",后者管"接入外部世界"。两者最终都变成 Day16 的 PluginTool / MCPTool,被 Agent 一视同仁地调用。
L02

插件为什么要单开一个 daemon 进程

Dify 的插件不是"在主进程里 import 一段代码",而是跑在一个独立的 plugin daemon(守护进程/服务)里。主程序(api/)通过 HTTP 内部 API 和它对话。配置里能看到这个 daemon 的地址(core/plugin/impl/base.py:43):

# api/core/plugin/impl/base.py:43
plugin_daemon_inner_api_baseurl = URL(str(dify_config.PLUGIN_DAEMON_URL))   # ★ daemon 的地址
...
PLUGIN_DAEMON_MAX_PATH_LENGTH = 4096          # 路径长度上限(安全)
PLUGIN_DAEMON_MAX_PATH_DECODE_DEPTH = 8       # 路径解码深度上限(防编码攻击)
PLUGIN_DAEMON_URL主程序不直接运行插件代码,而是把请求发到这个地址。plugin daemon 是另一个独立服务(Go 写的),专门负责加载、运行插件。这就是"进程隔离"的物理体现。
为什么隔离①稳定:插件崩溃只崩 daemon,主 API 不受影响;②安全:插件够不到主程序的数据库连接、内存、密钥;③技术自由:插件可以用任何语言写,只要能和 daemon 通信。
MAX_PATH 系列常量既然是隔空通过路径喊话,就要防"路径注入/超长/多重编码"攻击——这两个上限就是安全护栏。安全意识渗透在每一层。
大白话把插件想成"外包员工":你不会让外包直接进公司机房碰服务器,而是给他一个"对接窗口"(daemon 的内部 API),所有需求隔着窗口递条子。外包干得再烂,也只能搞砸他自己那摊,动不了你的核心系统。
L03

BasePluginClient:主程序怎么和 daemon 对话

所有和 daemon 的通信都走一个基类客户端 BasePluginClientcore/plugin/impl/base.py:68),核心是 _requestimpl/base.py:69):

# api/core/plugin/impl/base.py:69
def _request(self, method, path, headers=None, data=None, params=None, files=None) -> httpx.Response:
    """Make a request to the plugin daemon inner API."""
    url, headers, prepared_data, params, files = self._prepare_request(path, headers, data, params, files)
    request_kwargs = {"method": method, "url": url, "headers": headers,
                      "params": params, "files": files, "timeout": plugin_daemon_request_timeout}
    ...
    try:
        response = _httpx_client.request(**request_kwargs)          # ★ 就是发一个 HTTP 请求给 daemon
    except httpx.RequestError:
        logger.exception("Request to Plugin Daemon Service failed")
        raise PluginDaemonInnerError(code=-500, message="Request to Plugin Daemon Service failed")
    return response

请求前 _prepare_request 做了两件关键的安全 + 鉴权处理(impl/base.py:128):

# api/core/plugin/impl/base.py (_prepare_request 节选)
if any(seg == ".." for seg in decoded_path.split("/")):             # ① 防路径穿越
    raise ValueError(f"Invalid plugin daemon path: traversal sequence detected in {path!r}")
url = plugin_daemon_inner_api_baseurl / path
prepared_headers = dict(headers or {})
prepared_headers["X-Api-Key"] = dify_config.PLUGIN_DAEMON_KEY       # ★② 用内部密钥鉴权
self._inject_trace_headers(prepared_headers)                        # ③ 注入分布式追踪头(Day19)
_request = 发 HTTP本质上,"调用一个插件"对主程序来说就是"给 daemon 发一个 HTTP 请求"。上层各种客户端(impl/tool.pyimpl/model.py 等)都建在这个 _request 之上。
X-Api-Keydaemon 的内部 API 不是谁都能调的,主程序带上 PLUGIN_DAEMON_KEY 证明"我是合法的 Dify 主程序"。防止有人绕过主程序直接戳 daemon。
防路径穿越 ".."如果 path 里含 ..(想跳到上级目录)直接拒绝——经典的目录穿越防护。配合 L02 的解码深度上限,堵死"多重编码藏 .."的花招。
_inject_trace_headers把追踪 ID 注入请求头,这样一次调用穿过"主程序→daemon→插件"时,Day19 的可观测系统能把它们串成一条完整链路。
💡 设计取舍:进程隔离的代价隔离带来稳定和安全,代价是每次调插件都多一次进程间 HTTP 往返(比进程内直接调函数慢)。对于"调用工具"这种本就要走网络(调外部 API)的场景,这点开销可忽略;但它确实让部署更复杂(多了一个 daemon 要维护)。Dify 判断"安全+稳定"值这个价——对一个要跑第三方代码的平台,隔离几乎是必须的
L04

PluginService:安装、列举、管理

面向业务的门面是 PluginServicecore/plugin/plugin_service.py:81)。它不自己发 HTTP,而是委托给各种 Installer/Manager(它们底层都是 L03 的 BasePluginClient)。比如"列出某租户装了哪些插件":

# api/core/plugin/plugin_service.py (list_plugins 节选)
manager = PluginInstaller()
plugins = manager.list_plugins(tenant_id)     # ★ 委托给 installer,底层向 daemon 发请求
...
def fetch_plugin_model_providers(...):        # 拉某插件提供了哪些模型供应商
    ...
PluginService 是门面控制器/API 层只跟它打交道,不用知道底下有 PluginInstallerPluginAssetManagerPluginDebuggingClient 等一堆分工的客户端。又是"门面模式"(和 Day05 的 ModelInstance 同思路)。
list_plugins(tenant_id)按租户列插件——说明插件也是"多租户隔离"的:A 公司装的插件,B 公司看不到、用不了。
fetch_plugin_model_providersplugin_service.py:464)插件不只能提供工具,还能提供"模型供应商"。所以 Day05 的 ProviderManager 里,有些供应商其实是插件带进来的。
impl/ 目录core/plugin/impl/ 会发现 tool.py / model.py / endpoint.py / datasource.py 等——插件能扩展的能力面很广:工具、模型、数据源、HTTP 端点都行。
L05

MCP:一个协议,接入任意外部工具

MCP(Model Context Protocol)是一套开放协议,用 JSON-RPC 规范请求/响应。Dify 在 core/mcp/types.py 里声明了它遵循的协议版本和消息格式(core/mcp/types.py:26):

# api/core/mcp/types.py:26
LATEST_PROTOCOL_VERSION = "2025-06-18"          # ★ Dify 支持的 MCP 协议版本
SERVER_LATEST_PROTOCOL_VERSION = "2025-06-18"
...
# api/core/mcp/types.py:178
class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification
                             | JSONRPCResponse | JSONRPCError]):    # ★ 一条 MCP 消息 = 四种之一
    ...
JSON-RPCMCP 建在 JSON-RPC 之上:一条消息要么是请求(Request,要回复)、要么是通知(Notification,不要回复)、要么是响应(Response)、要么是错误(Error)。JSONRPCMessage 就是这四选一的联合类型。
协议版本客户端和服务器握手时要对齐版本(如 2025-06-18),防止双方对协议理解不一致。这是所有协议交互的第一步。
关键请求类型文件里还定义了 InitializeRequest(初始化握手)、CallToolRequest(调用工具,method=tools/call)等——正好对应 L06 里 client 会用到的两个动作。
💡 MCP 和插件的分工都是"往 Dify 里加能力",区别在:插件是 Dify 生态自己的包格式(有插件市场、要安装、跑在 Dify 的 daemon 里);MCP 是行业通用协议(外部工具服务器可能是任何团队、任何语言写的,Dify 只是作为一个"客户端"去连它)。一个像"应用商店里下 App",一个像"用标准 USB 线接别人的设备"。
L06

MCPClient:连接、降级、调用

MCPClientcore/mcp/mcp_client.py:20)是 Dify 作为 MCP 客户端的门面。它连接时有个漂亮的"自动降级"设计(mcp_client.py:61):

# api/core/mcp/mcp_client.py (connect 节选)
methods = {
    "mcp": streamablehttp_client,      # 首选:streamable http(较新的传输方式)
    "sse":  sse_client,                # 备选:SSE
}
...
    self.connect_server(sse_client, "sse")            # 先试 SSE
except ...:
    logger.debug("MCP connection failed with 'sse', falling back to 'mcp' method.")
    self.connect_server(streamablehttp_client, "mcp") # ★ 失败就降级换另一种传输

连上以后,"列工具"和"调工具"只是两行——它们把请求转成 L05 的 JSON-RPC 消息发给服务器(mcp_client.py:107mcp_client.py:114):

# api/core/mcp/mcp_client.py:107
def list_tools(self) -> list[Tool]:
    if not self._session:
        raise ValueError("Session not initialized.")
    response = self._session.list_tools()             # 问服务器"你有哪些工具"
    return response.tools

# api/core/mcp/mcp_client.py:114
def invoke_tool(self, tool_name: str, tool_args: dict[str, Any]) -> CallToolResult:
    if not self._session:
        raise ValueError("Session not initialized.")
    return self._session.call_tool(tool_name, tool_args)   # ★ 调用某个工具(发 tools/call 请求)
自动降级不同 MCP 服务器支持的传输方式不同。先试较新的方式,不行就换旧的——尽最大努力连上,而不是一种失败就报错。对用户友好。
session.initialize()连上后先握手初始化(core/mcp/session/client_session.py:112),对齐协议版本、交换能力清单。之后 list_tools/call_tool 才能用。
list_tools动态问服务器"你现在有哪些工具"。注意是运行时问的——外部服务器加了新工具,Dify 不用改代码就能发现。这就是协议标准化的红利。
invoke_tooltools/call 请求真正执行。最终这个 MCPClient 被包进 Day16 的 MCPTool,让 Agent 像调普通工具一样调它。
两条扩展通道:插件(进程隔离) vs MCP(协议标准) Dify 主程序Agent 一视同仁地调 Plugin Daemon独立进程·X-Api-Key 内部HTTP MCP Server外部·JSON-RPC MCP协议 自家插件市场·可扩展工具/模型/数据源 通用协议·任意语言的外部工具 两条通道进来的都变成 PluginTool / MCPTool → Day16 统一调用
图注:插件走内部 HTTP 连独立 daemon;MCP 走 JSON-RPC 连外部服务器。两者最终都归入 Day16 的工具抽象。
L07

串起来 + 今日小结

📝 真实值:Agent 调一个 MCP 工具查 GitHub issue 你在 Dify 里配了一个 MCP server 地址 → Dify 建 MCPClient → 先试 SSE 连接失败 → 自动降级用 streamable http 连上 → initialize() 握手,双方确认协议版本 2025-06-18list_tools() 发现服务器有个 search_issues 工具 → 这个工具被包成 MCPTool 出现在 Agent 的工具菜单里 → Day17 的 Agent 决定调它 → 走 Day16 的 ToolEngineMCPClient.invoke_tool("search_issues", {"repo":"dify","q":"bug"}) → 发 tools/call 请求 → 拿回 issue 列表 → 转成观察文本喂回模型。Dify 一行对接代码没改,就用上了一个外部工具。

👶 小白:插件和 MCP 我到底该用哪个?

👨‍🏫 老师:想用 Dify 插件市场里现成的、或想把能力发布给 Dify 社区 → 用插件。你已经有一个(或想快速搭一个)独立的工具服务、又不想打包成 Dify 插件格式 → 用 MCP,尤其当这个服务还要给别的 AI 应用(不只是 Dify)用时——MCP 是通用的,一次实现处处可接。简单说:深度融入 Dify 生态用插件,跨平台通用用 MCP。

🧠 今天你应该能回答

  • 插件为什么跑在独立 daemon 进程里?(稳定/安全/语言自由)
  • 主程序怎么和 daemon 通信?(BasePluginClient._request 发 HTTP,带 X-Api-Key
  • daemon 通信做了哪些安全防护?(防路径穿越、限制解码深度、内部密钥鉴权)
  • MCP 是什么?(基于 JSON-RPC 的开放协议,接入任意外部工具服务器)
  • MCPClient 连接的亮点?(SSE/streamable 自动降级、先 initialize 握手)
  • 插件和 MCP 进来后如何被使用?(都变成 Day16 的工具,Agent 统一调用)

✋ 10 分钟动手

cd /Users/bitmart/work/codes/github/AI_WORK/dify

# 1. 插件与 daemon 通信
sed -n '43,60p'   api/core/plugin/impl/base.py       # daemon 地址 + 安全常量
sed -n '69,135p'  api/core/plugin/impl/base.py       # _request + _prepare_request

# 2. 插件门面
grep -n "class PluginService\|PluginInstaller\|list_plugins" api/core/plugin/plugin_service.py

# 3. MCP 协议与客户端
sed -n '26,29p'   api/core/mcp/types.py              # 协议版本
sed -n '107,118p' api/core/mcp/mcp_client.py         # list_tools / invoke_tool
ls api/core/plugin/impl/  api/core/mcp/
明日预告 · Day 19:到今天,Dify 的"能力面"基本讲完了。但一个生产系统还需要"后台干活"和"看得见"。明天进 api/tasks/(Celery 异步任务队列——文档索引、发邮件这些慢活怎么甩到后台)和 core/ops/(可观测——一次对话怎么被追踪、上报到 Langfuse/LangSmith)。运维视角,收官前的最后一块拼图。
← Day 17 Agent Day 19 · 任务队列与可观测 →