feat: successfully install

This commit is contained in:
WangCham
2026-05-07 13:19:02 +08:00
parent 80911a3d91
commit 58f9ff94d3
9 changed files with 194 additions and 19 deletions

View File

@@ -31,6 +31,8 @@ class MCPRouterGroup(group.RouterGroup):
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) @self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str: async def _(server_name: str) -> str:
"""获取、更新或删除MCP服务器配置""" """获取、更新或删除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) server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
if server_data is None: if server_data is None:
@@ -57,6 +59,8 @@ class MCPRouterGroup(group.RouterGroup):
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str: async def _(server_name: str) -> str:
"""测试MCP服务器连接""" """测试MCP服务器连接"""
from urllib.parse import unquote
server_name = unquote(server_name)
server_data = await quart.request.json server_data = await quart.request.json
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) 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}) return self.success(data={'task_id': task_id})

View File

@@ -202,12 +202,150 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
except Exception: except Exception:
pass 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( async def install_plugin(
self, self,
install_source: PluginInstallSource, install_source: PluginInstallSource,
install_info: dict[str, Any], install_info: dict[str, Any],
task_context: taskmgr.TaskContext | None = None, 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: if install_source == PluginInstallSource.LOCAL:
# transfer file before install # transfer file before install
file_bytes = install_info['plugin_file'] file_bytes = install_info['plugin_file']

View File

@@ -233,8 +233,8 @@ export function SidebarDataProvider({
const resp = await httpClient.getMCPServers(); const resp = await httpClient.getMCPServers();
setMCPServers( setMCPServers(
resp.servers.map((server) => ({ resp.servers.map((server) => ({
id: server.name, id: server.name, // Keep __ for API calls
name: server.name, name: server.name.replace(/__/g, '/'), // Display with / for readability
enabled: server.enable, enabled: server.enable,
runtimeStatus: server.runtime_info?.status, runtimeStatus: server.runtime_info?.status,
})), })),

View File

@@ -39,7 +39,9 @@ export default function MCPDetailContent({ id }: { id: string }) {
setDetailEntityName(t('mcp.createServer')); setDetailEntityName(t('mcp.createServer'));
} else { } else {
const server = mcpServers.find((s) => s.id === id); 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); return () => setDetailEntityName(null);
}, [id, isCreateMode, mcpServers, setDetailEntityName, t]); }, [id, isCreateMode, mcpServers, setDetailEntityName, t]);

View File

@@ -309,7 +309,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
const server = resp.server ?? resp; const server = resp.server ?? resp;
const formValues: FormValues = { const formValues: FormValues = {
name: server.name, name: server.name.replace(/__/g, '/'), // Convert __ back to / for display
mode: server.mode, mode: server.mode,
url: '', url: '',
command: '', command: '',

View File

@@ -33,7 +33,7 @@ import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComp
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO'; import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import { getCloudServiceClientSync } from '@/app/infra/http'; import { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next'; 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 { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api'; import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
@@ -489,13 +489,44 @@ function MarketPageContent({
// 处理安装插件 // 处理安装插件
const handleInstallPlugin = useCallback( const handleInstallPlugin = useCallback(
async (author: string, pluginName: string) => { async (cardVO: PluginMarketCardVO) => {
try { 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( const response = await getCloudServiceClientSync().getPluginDetail(
author, cardVO.author,
pluginName, 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; const pluginV4: PluginV4 = response.plugin;
// Call the install function passed from parent // Call the install function passed from parent
@@ -505,7 +536,7 @@ function MarketPageContent({
toast.error(t('market.installFailed')); toast.error(t('market.installFailed'));
} }
}, },
[plugins, installPlugin, t], [installPlugin, t],
); );
// 清理定时器 // 清理定时器

View File

@@ -50,7 +50,7 @@ function RecommendationListRow({
}: { }: {
list: RecommendationList; list: RecommendationList;
tagNames: Record<string, string>; tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void; onInstall: (cardVO: PluginMarketCardVO) => void;
isLast: boolean; isLast: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -162,7 +162,7 @@ export function RecommendationLists({
}: { }: {
lists: RecommendationList[]; lists: RecommendationList[];
tagNames: Record<string, string>; tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void; onInstall: (cardVO: PluginMarketCardVO) => void;
}) { }) {
if (!lists || lists.length === 0) return null; if (!lists || lists.length === 0) return null;

View File

@@ -18,7 +18,7 @@ export default function PluginMarketCardComponent({
tagNames = {}, tagNames = {},
}: { }: {
cardVO: PluginMarketCardVO; cardVO: PluginMarketCardVO;
onInstall?: (author: string, pluginName: string) => void; onInstall?: (cardVO: PluginMarketCardVO) => void;
tagNames?: Record<string, string>; tagNames?: Record<string, string>;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -244,7 +244,7 @@ export default function PluginMarketCardComponent({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onInstall) { 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 ${ 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 ${

View File

@@ -804,7 +804,7 @@ export class BackendClient extends BaseHttpClient {
} }
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> { public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
return this.get(`/api/v1/mcp/servers/${serverName}`); return this.get(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`);
} }
public createMCPServer(server: MCPServer): Promise<AsyncTaskCreatedResp> { public createMCPServer(server: MCPServer): Promise<AsyncTaskCreatedResp> {
@@ -815,18 +815,18 @@ export class BackendClient extends BaseHttpClient {
serverName: string, serverName: string,
server: Partial<MCPServer>, server: Partial<MCPServer>,
): Promise<AsyncTaskCreatedResp> { ): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, server); return this.put(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`, server);
} }
public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> { public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
return this.delete(`/api/v1/mcp/servers/${serverName}`); return this.delete(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`);
} }
public toggleMCPServer( public toggleMCPServer(
serverName: string, serverName: string,
target_enabled: boolean, target_enabled: boolean,
): Promise<AsyncTaskCreatedResp> { ): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, { return this.put(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`, {
enable: target_enabled, enable: target_enabled,
}); });
} }
@@ -835,7 +835,7 @@ export class BackendClient extends BaseHttpClient {
serverName: string, serverName: string,
serverData: object, serverData: object,
): Promise<AsyncTaskCreatedResp> { ): Promise<AsyncTaskCreatedResp> {
return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData); return this.post(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/test`, serverData);
} }
public installMCPServerFromGithub( public installMCPServerFromGithub(