插件与 MCP:Dify 的"能力生态"怎么热插拔
Day16-17 的工具、模型都是 Dify 内置的。但 Dify 不可能内置世界上所有能力。于是有了两条"往里加东西"的通道:插件(Plugin)——第三方把工具/模型/数据源打包,Dify 装上就能用,且跑在独立进程里;MCP——一个开放协议,让 Dify 能连接任何"说 MCP 的"外部工具服务器。今天进 core/plugin/ 和 core/mcp/,弄清:①插件为什么要单开一个 daemon 进程、Dify 怎么和它通信;②PluginService 怎么管理安装;③MCP 是什么协议、MCPClient 怎么连上并调用外部工具。
痛点:能力怎么"热插拔"、还不拖垮主程序
PluginTool / MCPTool,被 Agent 一视同仁地调用。插件为什么要单开一个 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 系列常量既然是隔空通过路径喊话,就要防"路径注入/超长/多重编码"攻击——这两个上限就是安全护栏。安全意识渗透在每一层。BasePluginClient:主程序怎么和 daemon 对话
所有和 daemon 的通信都走一个基类客户端 BasePluginClient(core/plugin/impl/base.py:68),核心是 _request(impl/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.py、impl/model.py 等)都建在这个 _request 之上。X-Api-Keydaemon 的内部 API 不是谁都能调的,主程序带上 PLUGIN_DAEMON_KEY 证明"我是合法的 Dify 主程序"。防止有人绕过主程序直接戳 daemon。防路径穿越 ".."如果 path 里含 ..(想跳到上级目录)直接拒绝——经典的目录穿越防护。配合 L02 的解码深度上限,堵死"多重编码藏 .."的花招。_inject_trace_headers把追踪 ID 注入请求头,这样一次调用穿过"主程序→daemon→插件"时,Day19 的可观测系统能把它们串成一条完整链路。PluginService:安装、列举、管理
面向业务的门面是 PluginService(core/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 层只跟它打交道,不用知道底下有 PluginInstaller、PluginAssetManager、PluginDebuggingClient 等一堆分工的客户端。又是"门面模式"(和 Day05 的 ModelInstance 同思路)。list_plugins(tenant_id)按租户列插件——说明插件也是"多租户隔离"的:A 公司装的插件,B 公司看不到、用不了。fetch_plugin_model_providers(plugin_service.py:464)插件不只能提供工具,还能提供"模型供应商"。所以 Day05 的 ProviderManager 里,有些供应商其实是插件带进来的。impl/ 目录看 core/plugin/impl/ 会发现 tool.py / model.py / endpoint.py / datasource.py 等——插件能扩展的能力面很广:工具、模型、数据源、HTTP 端点都行。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 会用到的两个动作。MCPClient:连接、降级、调用
MCPClient(core/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:107 与 mcp_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_tool发 tools/call 请求真正执行。最终这个 MCPClient 被包进 Day16 的 MCPTool,让 Agent 像调普通工具一样调它。串起来 + 今日小结
MCPClient → 先试 SSE 连接失败 → 自动降级用 streamable http 连上 → initialize() 握手,双方确认协议版本 2025-06-18 → list_tools() 发现服务器有个 search_issues 工具 → 这个工具被包成 MCPTool 出现在 Agent 的工具菜单里 → Day17 的 Agent 决定调它 → 走 Day16 的 ToolEngine → MCPClient.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/
api/tasks/(Celery 异步任务队列——文档索引、发邮件这些慢活怎么甩到后台)和 core/ops/(可观测——一次对话怎么被追踪、上报到 Langfuse/LangSmith)。运维视角,收官前的最后一块拼图。