From 58f9ff94d337e09f2ca7e6fecddbdda93b236972 Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Thu, 7 May 2026 13:19:02 +0800 Subject: [PATCH] feat: successfully install --- .../http/controller/groups/resources/mcp.py | 4 + src/langbot/pkg/plugin/connector.py | 138 ++++++++++++++++++ .../home-sidebar/SidebarDataContext.tsx | 4 +- web/src/app/home/mcp/MCPDetailContent.tsx | 4 +- .../home/mcp/components/mcp-form/MCPForm.tsx | 2 +- .../plugin-market/PluginMarketComponent.tsx | 43 +++++- .../plugin-market/RecommendationLists.tsx | 4 +- .../PluginMarketCardComponent.tsx | 4 +- web/src/app/infra/http/BackendClient.ts | 10 +- 9 files changed, 194 insertions(+), 19 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py index ac91abff..6b727519 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -31,6 +31,8 @@ class MCPRouterGroup(group.RouterGroup): @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: """获取、更新或删除MCP服务器配置""" + from urllib.parse import unquote + server_name = unquote(server_name) server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) if server_data is None: @@ -57,6 +59,8 @@ class MCPRouterGroup(group.RouterGroup): @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: """测试MCP服务器连接""" + from urllib.parse import unquote + server_name = unquote(server_name) server_data = await quart.request.json task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) return self.success(data={'task_id': task_id}) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 964355ee..4e9e8f8e 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -202,12 +202,150 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): except Exception: pass + async def _install_mcp_from_marketplace( + self, + mcp_data: dict[str, Any], + task_context: taskmgr.TaskContext | None = None, + ): + """Install an MCP server from marketplace data.""" + from ..entity.persistence import mcp as persistence_mcp + import uuid + + config = mcp_data.get('config', {}) + url = config.get('url', '') + # Use __ instead of / to avoid URL routing issues with slashes + name = f"{mcp_data.get('author', '')}__{mcp_data.get('name', '')}" + + # Determine mode from URL + if 'sse' in url.lower(): + mode = 'sse' + elif url.startswith('http'): + mode = 'http' + else: + mode = 'stdio' + + # Build extra_args from config + extra_args = { + 'url': url, + 'timeout': config.get('timeout', 30), + 'sse_read_timeout': config.get('sse_read_timeout', 300), + } + + # Check if MCP server already exists + existing = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == name) + ) + if existing.scalar_one_or_none(): + self.ap.logger.info(f'MCP server {name} already exists, skipping installation') + return + + # Create MCP server record + server_uuid = str(uuid.uuid4()) + server_data = { + 'uuid': server_uuid, + 'name': name, + 'enable': True, + 'mode': mode, + 'extra_args': extra_args, + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data) + ) + + # Start the MCP server + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + server_entity = result.first() + if server_entity: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity) + if self.ap.tool_mgr.mcp_tool_loader: + mcp_task = asyncio.create_task( + self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config) + ) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(mcp_task) + + self.ap.logger.info(f'Installed MCP server {name} from marketplace') + + async def _install_skill_from_zip( + self, + file_bytes: bytes, + filename: str, + task_context: taskmgr.TaskContext | None = None, + ): + """Install a skill from marketplace ZIP data.""" + from ..api.http.service.skill import SkillService + + skill_service = SkillService(self.ap) + + self.ap.logger.info(f'Installing skill from marketplace ZIP ({len(file_bytes)} bytes)') + + # Install from ZIP using skill service + result = await skill_service.install_from_zip_upload( + file_bytes=file_bytes, + filename=filename + '.zip', + ) + self.ap.logger.info(f'Skill installed successfully: {result}') + async def install_plugin( self, install_source: PluginInstallSource, install_info: dict[str, Any], task_context: taskmgr.TaskContext | None = None, ): + if install_source == PluginInstallSource.MARKETPLACE: + # Handle marketplace plugin/mcp/skill installation + plugin_author = install_info.get('plugin_author', '') + plugin_name = install_info.get('plugin_name', '') + space_url = self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app').rstrip('/') + + # Try MCP endpoint first + async with httpx.AsyncClient(trust_env=True, timeout=15) as client: + mcp_resp = await client.get(f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}') + if mcp_resp.status_code == 200: + mcp_data = mcp_resp.json().get('data', {}).get('mcp', {}) + if mcp_data.get('config'): + # It's an MCP - create server locally + self.ap.logger.info(f'Installing MCP from marketplace: {plugin_author}/{plugin_name}') + if task_context: + task_context.set_current_action('installing mcp server') + await self._install_mcp_from_marketplace(mcp_data, task_context) + return + else: + raise Exception(f'MCP {plugin_author}/{plugin_name} has no config') + elif mcp_resp.status_code == 404: + # Try skill endpoint - download ZIP and install + self.ap.logger.info(f'Installing skill from marketplace: {plugin_author}/{plugin_name}') + if task_context: + task_context.set_current_action('installing skill from marketplace') + + # Get skill detail to find version + skill_resp = await client.get(f'{space_url}/api/v1/marketplace/skills/{plugin_author}/{plugin_name}') + if skill_resp.status_code == 200: + # Download the skill ZIP (no version needed - uses latest) + if task_context: + task_context.set_current_action('downloading skill package') + + download_resp = await client.get( + f'{space_url}/api/v1/marketplace/skills/download/{plugin_author}/{plugin_name}' + ) + if download_resp.status_code != 200: + raise Exception(f'Failed to download skill {plugin_author}/{plugin_name}: {download_resp.status_code}') + + file_bytes = download_resp.content + file_size = len(file_bytes) + self.ap.logger.info(f'Downloaded skill ZIP ({file_size} bytes)') + + # Install skill from ZIP using skill service + await self._install_skill_from_zip(file_bytes, f'{plugin_author}-{plugin_name}', task_context) + return + else: + raise Exception(f'Skill {plugin_author}/{plugin_name} not found in marketplace') + else: + mcp_resp.raise_for_status() + raise Exception(f'Failed to get MCP {plugin_author}/{plugin_name}') + if install_source == PluginInstallSource.LOCAL: # transfer file before install file_bytes = install_info['plugin_file'] diff --git a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx index 53529a60..e7478cb8 100644 --- a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx +++ b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx @@ -233,8 +233,8 @@ export function SidebarDataProvider({ const resp = await httpClient.getMCPServers(); setMCPServers( resp.servers.map((server) => ({ - id: server.name, - name: server.name, + id: server.name, // Keep __ for API calls + name: server.name.replace(/__/g, '/'), // Display with / for readability enabled: server.enable, runtimeStatus: server.runtime_info?.status, })), diff --git a/web/src/app/home/mcp/MCPDetailContent.tsx b/web/src/app/home/mcp/MCPDetailContent.tsx index 8b4325a3..49b2c61e 100644 --- a/web/src/app/home/mcp/MCPDetailContent.tsx +++ b/web/src/app/home/mcp/MCPDetailContent.tsx @@ -39,7 +39,9 @@ export default function MCPDetailContent({ id }: { id: string }) { setDetailEntityName(t('mcp.createServer')); } else { const server = mcpServers.find((s) => s.id === id); - setDetailEntityName(server?.name ?? id); + // Convert __ back to / for display (since / is used as separator in stored names) + const displayName = (server?.name ?? id).replace(/__/g, '/'); + setDetailEntityName(displayName); } return () => setDetailEntityName(null); }, [id, isCreateMode, mcpServers, setDetailEntityName, t]); diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index 4138d1c5..99f5079d 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -309,7 +309,7 @@ const MCPForm = forwardRef(function MCPForm( const server = resp.server ?? resp; const formValues: FormValues = { - name: server.name, + name: server.name.replace(/__/g, '/'), // Convert __ back to / for display mode: server.mode, url: '', command: '', diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index 27eeaaaf..6d4f028a 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -33,7 +33,7 @@ import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComp import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO'; import { getCloudServiceClientSync } from '@/app/infra/http'; import { useTranslation } from 'react-i18next'; -import { PluginV4 } from '@/app/infra/entities/plugin'; +import { PluginV4, PluginV4Status } from '@/app/infra/entities/plugin'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { toast } from 'sonner'; import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api'; @@ -489,13 +489,44 @@ function MarketPageContent({ // 处理安装插件 const handleInstallPlugin = useCallback( - async (author: string, pluginName: string) => { + async (cardVO: PluginMarketCardVO) => { try { - // Fetch full plugin details to get PluginV4 object + if (cardVO.type === 'mcp' || cardVO.type === 'skill') { + // For MCP and Skill, directly pass the data - backend will fetch from Space + const pluginV4: PluginV4 = { + id: 0, + plugin_id: `${cardVO.author}/${cardVO.pluginName}`, + mcp_id: cardVO.type === 'mcp' ? `${cardVO.author}/${cardVO.pluginName}` : undefined, + skill_id: cardVO.type === 'skill' ? `${cardVO.author}/${cardVO.pluginName}` : undefined, + author: cardVO.author, + name: cardVO.pluginName, + label: { en_US: cardVO.label, zh_Hans: cardVO.label }, + description: { en_US: cardVO.description, zh_Hans: cardVO.description }, + icon: cardVO.iconURL, + repository: cardVO.githubURL, + tags: cardVO.tags || [], + install_count: cardVO.installCount, + latest_version: cardVO.version, + components: cardVO.components || {}, + status: PluginV4Status.Live, + type: cardVO.type, + created_at: '', + updated_at: '', + }; + installPlugin(pluginV4); + return; + } + + // For plugin type, fetch full details via API const response = await getCloudServiceClientSync().getPluginDetail( - author, - pluginName, + cardVO.author, + cardVO.pluginName, ); + if (!response?.plugin) { + console.error('Failed to install plugin: plugin not found', { author: cardVO.author, pluginName: cardVO.pluginName }); + toast.error(t('market.installFailed')); + return; + } const pluginV4: PluginV4 = response.plugin; // Call the install function passed from parent @@ -505,7 +536,7 @@ function MarketPageContent({ toast.error(t('market.installFailed')); } }, - [plugins, installPlugin, t], + [installPlugin, t], ); // 清理定时器 diff --git a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx index a4849bd7..90c0ffde 100644 --- a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx +++ b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx @@ -50,7 +50,7 @@ function RecommendationListRow({ }: { list: RecommendationList; tagNames: Record; - onInstall: (author: string, pluginName: string) => void; + onInstall: (cardVO: PluginMarketCardVO) => void; isLast: boolean; }) { const { t } = useTranslation(); @@ -162,7 +162,7 @@ export function RecommendationLists({ }: { lists: RecommendationList[]; tagNames: Record; - onInstall: (author: string, pluginName: string) => void; + onInstall: (cardVO: PluginMarketCardVO) => void; }) { if (!lists || lists.length === 0) return null; diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx index 004d41d3..dad91856 100644 --- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx @@ -18,7 +18,7 @@ export default function PluginMarketCardComponent({ tagNames = {}, }: { cardVO: PluginMarketCardVO; - onInstall?: (author: string, pluginName: string) => void; + onInstall?: (cardVO: PluginMarketCardVO) => void; tagNames?: Record; }) { const { t } = useTranslation(); @@ -244,7 +244,7 @@ export default function PluginMarketCardComponent({ onClick={(e) => { e.stopPropagation(); if (onInstall) { - onInstall(cardVO.author, cardVO.pluginName); + onInstall(cardVO); } }} className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${ diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 10bb15e5..2dd3f959 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -804,7 +804,7 @@ export class BackendClient extends BaseHttpClient { } public getMCPServer(serverName: string): Promise { - return this.get(`/api/v1/mcp/servers/${serverName}`); + return this.get(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`); } public createMCPServer(server: MCPServer): Promise { @@ -815,18 +815,18 @@ export class BackendClient extends BaseHttpClient { serverName: string, server: Partial, ): Promise { - return this.put(`/api/v1/mcp/servers/${serverName}`, server); + return this.put(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`, server); } public deleteMCPServer(serverName: string): Promise { - return this.delete(`/api/v1/mcp/servers/${serverName}`); + return this.delete(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`); } public toggleMCPServer( serverName: string, target_enabled: boolean, ): Promise { - return this.put(`/api/v1/mcp/servers/${serverName}`, { + return this.put(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`, { enable: target_enabled, }); } @@ -835,7 +835,7 @@ export class BackendClient extends BaseHttpClient { serverName: string, serverData: object, ): Promise { - return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData); + return this.post(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/test`, serverData); } public installMCPServerFromGithub(