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 <rockchinq@gmail.com>
This commit is contained in:
Copilot
2025-11-06 19:38:12 +08:00
committed by GitHub
parent 68eb0290e0
commit cb48221ed3
12 changed files with 334 additions and 84 deletions

View File

@@ -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<Plugin[]>([]);
const [allPlugins, setAllPlugins] = useState<Plugin[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [tempSelectedIds, setTempSelectedIds] = useState<string[]>([]);
const [selectedMCPServers, setSelectedMCPServers] = useState<MCPServer[]>([]);
const [allMCPServers, setAllMCPServers] = useState<MCPServer[]>([]);
const [pluginDialogOpen, setPluginDialogOpen] = useState(false);
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const [tempSelectedPluginIds, setTempSelectedPluginIds] = useState<string[]>(
[],
);
const [tempSelectedMCPIds, setTempSelectedMCPIds] = useState<string[]>([]);
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 (
<div className="space-y-4">
<div className="space-y-2">
{selectedPlugins.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noPluginsSelected')}
</p>
</div>
) : (
<div className="space-y-2">
{selectedPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
return (
<div className="space-y-6">
{/* Plugins Section */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.pluginsTitle')}
</h3>
<div className="space-y-2">
{selectedPlugins.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noPluginsSelected')}
</p>
</div>
) : (
<div className="space-y-2">
{selectedPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
return (
<div
key={pluginId}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1 flex items-center gap-3">
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</div>
{!plugin.enabled && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemovePlugin(pluginId)}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
)}
</div>
<Button
onClick={handleOpenPluginDialog}
variant="outline"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addPlugin')}
</Button>
</div>
{/* MCP Servers Section */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.mcpServersTitle')}
</h3>
<div className="space-y-2">
{selectedMCPServers.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noMCPServersSelected')}
</p>
</div>
) : (
<div className="space-y-2">
{selectedMCPServers.map((server) => (
<div
key={pluginId}
key={server.uuid}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1 flex items-center gap-3">
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
{!plugin.enabled && (
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-white" />
<span className="text-xs text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</Badge>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
@@ -179,23 +304,28 @@ export default function PipelineExtension({
<Button
variant="ghost"
size="icon"
onClick={() => handleRemovePlugin(pluginId)}
onClick={() => handleRemoveMCPServer(server.uuid || '')}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
)}
))}
</div>
)}
</div>
<Button
onClick={handleOpenMCPDialog}
variant="outline"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addMCPServer')}
</Button>
</div>
<Button onClick={handleOpenDialog} variant="outline" className="w-full">
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addPlugin')}
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{/* Plugin Selection Dialog */}
<Dialog open={pluginDialogOpen} onOpenChange={setPluginDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('pipelines.extensions.selectPlugins')}</DialogTitle>
@@ -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 (
<div
key={pluginId}
@@ -245,10 +375,71 @@ export default function PipelineExtension({
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
<Button
variant="outline"
onClick={() => setPluginDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirmSelection}>
<Button onClick={handleConfirmPluginSelection}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* MCP Server Selection Dialog */}
<Dialog open={mcpDialogOpen} onOpenChange={setMcpDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{t('pipelines.extensions.selectMCPServers')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allMCPServers.map((server) => {
const isSelected = tempSelectedMCPIds.includes(server.uuid || '');
return (
<div
key={server.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleMCPServer(server.uuid || '')}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<div className="flex items-center gap-1 mt-1">
<Wrench className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</div>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMcpDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirmMCPSelection}>
{t('common.confirm')}
</Button>
</DialogFooter>

View File

@@ -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<object> {
return this.put(`/api/v1/pipelines/${uuid}/extensions`, {
bound_plugins,
bound_mcp_servers,
});
}

View File

@@ -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',

View File

@@ -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: 'パイプラインのチャット',

View File

@@ -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: '流水线对话',

View File

@@ -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: '流程線對話',