From cb48221ed34c05a9787a972958f3ec1d28cbf487 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:38:12 +0800 Subject: [PATCH] feat: add MCP server selection to pipeline extensions (#1754) * Initial plan * Backend: Add MCP server selection support to pipeline extensions Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Frontend: Add MCP server selection UI to pipeline extensions Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * perf: ui * perf: ui * perf: desc for extension page --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: Junyan Qin --- .../controller/groups/pipelines/pipelines.py | 10 +- pkg/api/http/service/pipeline.py | 6 +- pkg/pipeline/pipelinemgr.py | 11 +- pkg/pipeline/preproc/preproc.py | 9 +- pkg/provider/tools/loaders/mcp.py | 16 +- pkg/provider/tools/toolmgr.py | 4 +- .../pipeline-extensions/PipelineExtension.tsx | 321 ++++++++++++++---- web/src/app/infra/http/BackendClient.ts | 4 + web/src/i18n/locales/en-US.ts | 8 +- web/src/i18n/locales/ja-JP.ts | 9 +- web/src/i18n/locales/zh-Hans.ts | 10 +- web/src/i18n/locales/zh-Hant.ts | 10 +- 12 files changed, 334 insertions(+), 84 deletions(-) diff --git a/pkg/api/http/controller/groups/pipelines/pipelines.py b/pkg/api/http/controller/groups/pipelines/pipelines.py index c143b9d6..32b9fff7 100644 --- a/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -56,18 +56,24 @@ class PipelinesRouterGroup(group.RouterGroup): return self.http_status(404, -1, 'pipeline not found') plugins = await self.ap.plugin_connector.list_plugins() + mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) return self.success( data={ 'bound_plugins': pipeline.get('extensions_preferences', {}).get('plugins', []), 'available_plugins': plugins, + 'bound_mcp_servers': pipeline.get('extensions_preferences', {}).get('mcp_servers', []), + 'available_mcp_servers': mcp_servers, } ) elif quart.request.method == 'PUT': - # Update bound plugins for this pipeline + # Update bound plugins and MCP servers for this pipeline json_data = await quart.request.json bound_plugins = json_data.get('bound_plugins', []) + bound_mcp_servers = json_data.get('bound_mcp_servers', []) - await self.ap.pipeline_service.update_pipeline_extensions(pipeline_uuid, bound_plugins) + await self.ap.pipeline_service.update_pipeline_extensions( + pipeline_uuid, bound_plugins, bound_mcp_servers + ) return self.success() diff --git a/pkg/api/http/service/pipeline.py b/pkg/api/http/service/pipeline.py index 01a8b29f..2e00a74d 100644 --- a/pkg/api/http/service/pipeline.py +++ b/pkg/api/http/service/pipeline.py @@ -137,8 +137,8 @@ class PipelineService: ) await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) - async def update_pipeline_extensions(self, pipeline_uuid: str, bound_plugins: list[dict]) -> None: - """Update the bound plugins for a pipeline""" + async def update_pipeline_extensions(self, pipeline_uuid: str, bound_plugins: list[dict], bound_mcp_servers: list[str] = None) -> None: + """Update the bound plugins and MCP servers for a pipeline""" # Get current pipeline result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( @@ -153,6 +153,8 @@ class PipelineService: # Update extensions_preferences extensions_preferences = pipeline.extensions_preferences or {} extensions_preferences['plugins'] = bound_plugins + if bound_mcp_servers is not None: + extensions_preferences['mcp_servers'] = bound_mcp_servers await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) diff --git a/pkg/pipeline/pipelinemgr.py b/pkg/pipeline/pipelinemgr.py index 37979131..c4206c0d 100644 --- a/pkg/pipeline/pipelinemgr.py +++ b/pkg/pipeline/pipelinemgr.py @@ -71,6 +71,9 @@ class RuntimePipeline: bound_plugins: list[str] """绑定到此流水线的插件列表(格式:author/plugin_name)""" + + bound_mcp_servers: list[str] + """绑定到此流水线的MCP服务器列表(格式:uuid)""" def __init__( self, @@ -82,15 +85,19 @@ class RuntimePipeline: self.pipeline_entity = pipeline_entity self.stage_containers = stage_containers - # Extract bound plugins from extensions_preferences + # Extract bound plugins and MCP servers from extensions_preferences extensions_prefs = pipeline_entity.extensions_preferences or {} plugin_list = extensions_prefs.get('plugins', []) self.bound_plugins = [f"{p['author']}/{p['name']}" for p in plugin_list] if plugin_list else [] + + mcp_server_list = extensions_prefs.get('mcp_servers', []) + self.bound_mcp_servers = mcp_server_list if mcp_server_list else [] async def run(self, query: pipeline_query.Query): query.pipeline_config = self.pipeline_entity.config - # Store bound plugins in query for filtering + # Store bound plugins and MCP servers in query for filtering query.variables['_pipeline_bound_plugins'] = self.bound_plugins + query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers await self.process_query(query) async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult): diff --git a/pkg/pipeline/preproc/preproc.py b/pkg/pipeline/preproc/preproc.py index 8211f692..f6f98abb 100644 --- a/pkg/pipeline/preproc/preproc.py +++ b/pkg/pipeline/preproc/preproc.py @@ -65,9 +65,14 @@ class PreProcessor(stage.PipelineStage): query.use_llm_model_uuid = llm_model.model_entity.uuid if llm_model.model_entity.abilities.__contains__('func_call'): - # Get bound plugins for filtering tools + # Get bound plugins and MCP servers for filtering tools bound_plugins = query.variables.get('_pipeline_bound_plugins', None) - query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins) + bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) + query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) + + self.ap.logger.debug(f'Bound plugins: {bound_plugins}') + self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') + self.ap.logger.debug(f'Use funcs: {query.use_funcs}') variables = { 'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}', diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 65130438..2e564bf4 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -30,6 +30,8 @@ class RuntimeMCPSession: server_name: str + server_uuid: str + server_config: dict session: ClientSession @@ -43,7 +45,6 @@ class RuntimeMCPSession: # connected: bool status: MCPSessionStatus - _lifecycle_task: asyncio.Task | None _shutdown_event: asyncio.Event @@ -52,6 +53,7 @@ class RuntimeMCPSession: def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application): self.server_name = server_name + self.server_uuid = server_config.get('uuid', '') self.server_config = server_config self.ap = ap self.enable = enable @@ -286,12 +288,14 @@ class MCPLoader(loader.ToolLoader): """ name = server_config['name'] + uuid = server_config['uuid'] mode = server_config['mode'] enable = server_config['enable'] extra_args = server_config.get('extra_args', {}) mixed_config = { 'name': name, + 'uuid': uuid, 'mode': mode, 'enable': enable, **extra_args, @@ -301,11 +305,17 @@ class MCPLoader(loader.ToolLoader): return session - async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: + async def get_tools(self, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]: all_functions = [] for session in self.sessions.values(): - all_functions.extend(session.get_tools()) + # If bound_mcp_servers is specified, only include tools from those servers + if bound_mcp_servers is not None: + if session.server_uuid in bound_mcp_servers: + all_functions.extend(session.get_tools()) + else: + # If no bound servers specified, include all tools + all_functions.extend(session.get_tools()) self._last_listed_functions = all_functions diff --git a/pkg/provider/tools/toolmgr.py b/pkg/provider/tools/toolmgr.py index dea1a36a..a9379e80 100644 --- a/pkg/provider/tools/toolmgr.py +++ b/pkg/provider/tools/toolmgr.py @@ -28,12 +28,12 @@ class ToolManager: self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap) await self.mcp_tool_loader.initialize() - async def get_all_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: + async def get_all_tools(self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]: """获取所有函数""" all_functions: list[resource_tool.LLMTool] = [] all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) - all_functions.extend(await self.mcp_tool_loader.get_tools(bound_plugins)) + all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers)) return all_functions diff --git a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx index 77220067..0bd7d664 100644 --- a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx +++ b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx @@ -14,9 +14,10 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; -import { Plus, X } from 'lucide-react'; +import { Plus, X, Server, Wrench } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Plugin } from '@/app/infra/entities/plugin'; +import { MCPServer } from '@/app/infra/entities/api'; import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; export default function PipelineExtension({ @@ -28,8 +29,14 @@ export default function PipelineExtension({ const [loading, setLoading] = useState(true); const [selectedPlugins, setSelectedPlugins] = useState([]); const [allPlugins, setAllPlugins] = useState([]); - const [dialogOpen, setDialogOpen] = useState(false); - const [tempSelectedIds, setTempSelectedIds] = useState([]); + const [selectedMCPServers, setSelectedMCPServers] = useState([]); + const [allMCPServers, setAllMCPServers] = useState([]); + const [pluginDialogOpen, setPluginDialogOpen] = useState(false); + const [mcpDialogOpen, setMcpDialogOpen] = useState(false); + const [tempSelectedPluginIds, setTempSelectedPluginIds] = useState( + [], + ); + const [tempSelectedMCPIds, setTempSelectedMCPIds] = useState([]); useEffect(() => { loadExtensions(); @@ -56,6 +63,15 @@ export default function PipelineExtension({ setSelectedPlugins(selected); setAllPlugins(data.available_plugins); + + // Load MCP servers + const boundMCPServerIds = new Set(data.bound_mcp_servers || []); + const selectedMCP = data.available_mcp_servers.filter((server) => + boundMCPServerIds.has(server.uuid || ''), + ); + + setSelectedMCPServers(selectedMCP); + setAllMCPServers(data.available_mcp_servers); } catch (error) { console.error('Failed to load extensions:', error); toast.error(t('pipelines.extensions.loadError')); @@ -64,7 +80,7 @@ export default function PipelineExtension({ } }; - const saveToBackend = async (plugins: Plugin[]) => { + const saveToBackend = async (plugins: Plugin[], mcpServers: MCPServer[]) => { try { const boundPluginsArray = plugins.map((plugin) => { const metadata = plugin.manifest.manifest.metadata; @@ -74,9 +90,12 @@ export default function PipelineExtension({ }; }); + const boundMCPServerIds = mcpServers.map((server) => server.uuid || ''); + await backendClient.updatePipelineExtensions( pipelineId, boundPluginsArray, + boundMCPServerIds, ); toast.success(t('pipelines.extensions.saveSuccess')); } catch (error) { @@ -92,29 +111,57 @@ export default function PipelineExtension({ (p) => getPluginId(p) !== pluginId, ); setSelectedPlugins(newPlugins); - await saveToBackend(newPlugins); + await saveToBackend(newPlugins, selectedMCPServers); }; - const handleOpenDialog = () => { - setTempSelectedIds(selectedPlugins.map((p) => getPluginId(p))); - setDialogOpen(true); + const handleRemoveMCPServer = async (serverUuid: string) => { + const newServers = selectedMCPServers.filter((s) => s.uuid !== serverUuid); + setSelectedMCPServers(newServers); + await saveToBackend(selectedPlugins, newServers); + }; + + const handleOpenPluginDialog = () => { + setTempSelectedPluginIds(selectedPlugins.map((p) => getPluginId(p))); + setPluginDialogOpen(true); + }; + + const handleOpenMCPDialog = () => { + setTempSelectedMCPIds(selectedMCPServers.map((s) => s.uuid || '')); + setMcpDialogOpen(true); }; const handleTogglePlugin = (pluginId: string) => { - setTempSelectedIds((prev) => + setTempSelectedPluginIds((prev) => prev.includes(pluginId) ? prev.filter((id) => id !== pluginId) : [...prev, pluginId], ); }; - const handleConfirmSelection = async () => { + const handleToggleMCPServer = (serverUuid: string) => { + setTempSelectedMCPIds((prev) => + prev.includes(serverUuid) + ? prev.filter((id) => id !== serverUuid) + : [...prev, serverUuid], + ); + }; + + const handleConfirmPluginSelection = async () => { const newSelected = allPlugins.filter((p) => - tempSelectedIds.includes(getPluginId(p)), + tempSelectedPluginIds.includes(getPluginId(p)), ); setSelectedPlugins(newSelected); - setDialogOpen(false); - await saveToBackend(newSelected); + setPluginDialogOpen(false); + await saveToBackend(newSelected, selectedMCPServers); + }; + + const handleConfirmMCPSelection = async () => { + const newSelected = allMCPServers.filter((s) => + tempSelectedMCPIds.includes(s.uuid || ''), + ); + setSelectedMCPServers(newSelected); + setMcpDialogOpen(false); + await saveToBackend(selectedPlugins, newSelected); }; if (loading) { @@ -128,49 +175,127 @@ export default function PipelineExtension({ } return ( -
-
- {selectedPlugins.length === 0 ? ( -
-

- {t('pipelines.extensions.noPluginsSelected')} -

-
- ) : ( -
- {selectedPlugins.map((plugin) => { - const pluginId = getPluginId(plugin); - const metadata = plugin.manifest.manifest.metadata; - return ( +
+ {/* Plugins Section */} +
+

+ {t('pipelines.extensions.pluginsTitle')} +

+
+ {selectedPlugins.length === 0 ? ( +
+

+ {t('pipelines.extensions.noPluginsSelected')} +

+
+ ) : ( +
+ {selectedPlugins.map((plugin) => { + const pluginId = getPluginId(plugin); + const metadata = plugin.manifest.manifest.metadata; + return ( +
+
+ {metadata.name} +
+
{metadata.name}
+
+ {metadata.author} • v{metadata.version} +
+
+ +
+
+ {!plugin.enabled && ( + + {t('pipelines.extensions.disabled')} + + )} +
+ +
+ ); + })} +
+ )} +
+ + +
+ + {/* MCP Servers Section */} +
+

+ {t('pipelines.extensions.mcpServersTitle')} +

+
+ {selectedMCPServers.length === 0 ? ( +
+

+ {t('pipelines.extensions.noMCPServersSelected')} +

+
+ ) : ( +
+ {selectedMCPServers.map((server) => (
- {metadata.name} -
-
{metadata.name}
-
- {metadata.author} • v{metadata.version} -
-
- -
+
+
- {!plugin.enabled && ( +
+
{server.name}
+
+ {server.mode} +
+ {server.runtime_info && + server.runtime_info.status === 'connected' && ( + + + + {t('pipelines.extensions.toolCount', { + count: server.runtime_info.tool_count || 0, + })} + + + )} +
+ {!server.enable && ( {t('pipelines.extensions.disabled')} @@ -179,23 +304,28 @@ export default function PipelineExtension({
- ); - })} -
- )} + ))} +
+ )} +
+ +
- - - + {/* Plugin Selection Dialog */} + {t('pipelines.extensions.selectPlugins')} @@ -204,7 +334,7 @@ export default function PipelineExtension({ {allPlugins.map((plugin) => { const pluginId = getPluginId(plugin); const metadata = plugin.manifest.manifest.metadata; - const isSelected = tempSelectedIds.includes(pluginId); + const isSelected = tempSelectedPluginIds.includes(pluginId); return (
- - + + +
+ + {/* MCP Server Selection Dialog */} + + + + + {t('pipelines.extensions.selectMCPServers')} + + +
+ {allMCPServers.map((server) => { + const isSelected = tempSelectedMCPIds.includes(server.uuid || ''); + return ( +
handleToggleMCPServer(server.uuid || '')} + > + +
+ +
+
+
{server.name}
+
+ {server.mode} +
+ {server.runtime_info && + server.runtime_info.status === 'connected' && ( +
+ + + {t('pipelines.extensions.toolCount', { + count: server.runtime_info.tool_count || 0, + })} + +
+ )} +
+ {!server.enable && ( + + {t('pipelines.extensions.disabled')} + + )} +
+ ); + })} +
+ + + diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 28f9c668..99391376 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -173,6 +173,8 @@ export class BackendClient extends BaseHttpClient { public getPipelineExtensions(uuid: string): Promise<{ bound_plugins: Array<{ author: string; name: string }>; available_plugins: Plugin[]; + bound_mcp_servers: string[]; + available_mcp_servers: MCPServer[]; }> { return this.get(`/api/v1/pipelines/${uuid}/extensions`); } @@ -180,9 +182,11 @@ export class BackendClient extends BaseHttpClient { public updatePipelineExtensions( uuid: string, bound_plugins: Array<{ author: string; name: string }>, + bound_mcp_servers: string[], ): Promise { return this.put(`/api/v1/pipelines/${uuid}/extensions`, { bound_plugins, + bound_mcp_servers, }); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 0646f418..496ca87c 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -154,7 +154,7 @@ const enUS = { plugins: { title: 'Extensions', description: - 'Install and configure plugins to extend LangBot functionality', + 'Install and configure plugins to extend functionality, please select them in the pipeline configuration', createPlugin: 'Create Plugin', editPlugin: 'Edit Plugin', installed: 'Installed', @@ -438,6 +438,12 @@ const enUS = { noPluginsSelected: 'No plugins selected', addPlugin: 'Add Plugin', selectPlugins: 'Select Plugins', + pluginsTitle: 'Plugins', + mcpServersTitle: 'MCP Servers', + noMCPServersSelected: 'No MCP servers selected', + addMCPServer: 'Add MCP Server', + selectMCPServers: 'Select MCP Servers', + toolCount: '{{count}} tools', }, debugDialog: { title: 'Pipeline Chat', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 696a538f..fb4513eb 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -155,7 +155,8 @@ const jaJP = { }, plugins: { title: '拡張機能', - description: 'LangBotの機能を拡張するプラグインをインストール・設定', + description: + 'LangBotの機能を拡張するプラグインをインストール・設定。流水線設定で使用します', createPlugin: 'プラグインを作成', editPlugin: 'プラグインを編集', installed: 'インストール済み', @@ -440,6 +441,12 @@ const jaJP = { noPluginsSelected: 'プラグインが選択されていません', addPlugin: 'プラグインを追加', selectPlugins: 'プラグインを選択', + pluginsTitle: 'プラグイン', + mcpServersTitle: 'MCPサーバー', + noMCPServersSelected: 'MCPサーバーが選択されていません', + addMCPServer: 'MCPサーバーを追加', + selectMCPServers: 'MCPサーバーを選択', + toolCount: '{{count}}個のツール', }, debugDialog: { title: 'パイプラインのチャット', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 2d3627b1..17444438 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -150,7 +150,7 @@ const zhHans = { }, plugins: { title: '插件扩展', - description: '安装和配置用于扩展 LangBot 功能的插件', + description: '安装和配置用于扩展功能的插件,请在流水线配置中选用', createPlugin: '创建插件', editPlugin: '编辑插件', installed: '已安装', @@ -413,7 +413,7 @@ const zhHans = { deleteSuccess: '删除成功', deleteError: '删除失败:', extensions: { - title: '插件集成', + title: '扩展集成', loadError: '加载插件列表失败', saveSuccess: '保存成功', saveError: '保存失败', @@ -422,6 +422,12 @@ const zhHans = { noPluginsSelected: '未选择任何插件', addPlugin: '添加插件', selectPlugins: '选择插件', + pluginsTitle: '插件', + mcpServersTitle: 'MCP 服务器', + noMCPServersSelected: '未选择任何 MCP 服务器', + addMCPServer: '添加 MCP 服务器', + selectMCPServers: '选择 MCP 服务器', + toolCount: '{{count}} 个工具', }, debugDialog: { title: '流水线对话', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 68a1fb56..b27a4511 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -150,7 +150,7 @@ const zhHant = { }, plugins: { title: '外掛擴展', - description: '安裝和設定用於擴展 LangBot 功能的外掛', + description: '安裝和設定用於擴展功能的外掛,請在流程線配置中選用', createPlugin: '建立外掛', editPlugin: '編輯外掛', installed: '已安裝', @@ -411,7 +411,7 @@ const zhHant = { deleteSuccess: '刪除成功', deleteError: '刪除失敗:', extensions: { - title: '插件集成', + title: '擴展集成', loadError: '載入插件清單失敗', saveSuccess: '儲存成功', saveError: '儲存失敗', @@ -420,6 +420,12 @@ const zhHant = { noPluginsSelected: '未選擇任何插件', addPlugin: '新增插件', selectPlugins: '選擇插件', + pluginsTitle: '插件', + mcpServersTitle: 'MCP 伺服器', + noMCPServersSelected: '未選擇任何 MCP 伺服器', + addMCPServer: '新增 MCP 伺服器', + selectMCPServers: '選擇 MCP 伺服器', + toolCount: '{{count}} 個工具', }, debugDialog: { title: '流程線對話',