From 23fa47b07eb02ed002465dec8bc963da20d10657 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 27 Mar 2026 19:59:34 +0800 Subject: [PATCH] feat(web): refactor MCP servers as sidebar entities and improve sidebar footer - Refactor MCP servers to be managed as collapsible sidebar sub-items with ?id= detail routing and inline form (matching bots/pipelines pattern) - Add MCPDetailContent with create/edit modes, enable toggle, and danger zone - Extract MCPForm as standalone inline form from MCPFormDialog - Move API Integration to standalone sidebar footer button - Add GitHub star CTA with live star count badge in user dropdown menu - Add MCP server status dot indicators in sidebar (green/gray for enabled/disabled) - Add i18n keys for MCP detail page and GitHub star CTA in all 4 locales --- web/src/app/global.css | 21 + .../components/home-sidebar/HomeSidebar.tsx | 86 +- .../home-sidebar/SidebarDataContext.tsx | 29 +- web/src/app/home/mcp/MCPDetailContent.tsx | 272 ++++++ .../home/mcp/components/mcp-form/MCPForm.tsx | 913 ++++++++++++++++++ web/src/app/home/mcp/page.tsx | 98 +- web/src/app/infra/http/CloudServiceClient.ts | 14 + web/src/i18n/locales/en-US.ts | 8 + web/src/i18n/locales/ja-JP.ts | 6 + web/src/i18n/locales/zh-Hans.ts | 6 + web/src/i18n/locales/zh-Hant.ts | 6 + 11 files changed, 1359 insertions(+), 100 deletions(-) create mode 100644 web/src/app/home/mcp/MCPDetailContent.tsx create mode 100644 web/src/app/home/mcp/components/mcp-form/MCPForm.tsx diff --git a/web/src/app/global.css b/web/src/app/global.css index 4d3a5d37..2c7722a6 100644 --- a/web/src/app/global.css +++ b/web/src/app/global.css @@ -114,6 +114,7 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --animate-twinkle: twinkle 1.5s ease-in-out infinite; } .dark { @@ -158,3 +159,23 @@ @apply bg-background text-foreground; } } + +@keyframes twinkle { + 0%, + 100% { + opacity: 1; + transform: scale(1) rotate(0deg); + } + 25% { + opacity: 0.6; + transform: scale(0.85) rotate(-8deg); + } + 50% { + opacity: 1; + transform: scale(1.15) rotate(4deg); + } + 75% { + opacity: 0.7; + transform: scale(0.95) rotate(-4deg); + } +} diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 46570585..bd4d4af1 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -18,6 +18,7 @@ import { LogOut, KeyRound, Settings, + Star, Ellipsis, ArrowUp, ExternalLink, @@ -115,6 +116,7 @@ const ENTITY_CATEGORY_IDS = [ 'pipelines', 'knowledge', 'plugins', + 'mcp', ] as const; type EntityCategoryId = (typeof ENTITY_CATEGORY_IDS)[number]; @@ -124,6 +126,7 @@ const DETAIL_PAGE_CATEGORIES: EntityCategoryId[] = [ 'pipelines', 'knowledge', 'plugins', + 'mcp', ]; // Categories that support creating new entities from the sidebar @@ -131,6 +134,7 @@ const CREATABLE_CATEGORIES: EntityCategoryId[] = [ 'bots', 'pipelines', 'knowledge', + 'mcp', ]; // Categories where clicking the parent only toggles collapse (no list page) @@ -138,6 +142,7 @@ const COLLAPSIBLE_ONLY_CATEGORIES: EntityCategoryId[] = [ 'bots', 'pipelines', 'knowledge', + 'mcp', ]; function isEntityCategory(id: string): id is EntityCategoryId { @@ -147,12 +152,13 @@ function isEntityCategory(id: string): id is EntityCategoryId { // Map sidebar config IDs to SidebarDataContext keys const ENTITY_KEY_MAP: Record< EntityCategoryId, - 'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' + 'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers' > = { bots: 'bots', pipelines: 'pipelines', knowledge: 'knowledgeBases', plugins: 'plugins', + mcp: 'mcpServers', }; // Route prefix map for entity detail pages @@ -161,6 +167,7 @@ const ENTITY_ROUTE_MAP: Record = { pipelines: '/home/pipelines', knowledge: '/home/knowledge', plugins: '/home/plugins', + mcp: '/home/mcp', }; // localStorage key for collapsible section open/closed state @@ -324,6 +331,7 @@ function NavItems({ const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id); const isPlugin = config.id === 'plugins'; const isBot = config.id === 'bots'; + const isMCP = config.id === 'mcp'; const isActive = selectedChild?.id === config.id || pathname === routePrefix || @@ -400,7 +408,7 @@ function NavItems({ alt="" className="size-4 rounded" /> - {isBot && ( + {(isBot || isMCP) && ( )} + ) : isMCP ? ( + ) : null} {item.name} @@ -447,7 +464,7 @@ function NavItems({ alt="" className="size-4 rounded" /> - {isBot && ( + {(isBot || isMCP) && ( )} + ) : isMCP ? ( + ) : null} {item.name} {item.debug && ( @@ -914,7 +940,8 @@ export default function HomeSidebar({ const [versionDialogOpen, setVersionDialogOpen] = useState(false); const [modelsDialogOpen, setModelsDialogOpen] = useState(false); const [userEmail, setUserEmail] = useState(''); - + const [starCount, setStarCount] = useState(null); + const [userMenuOpen, setUserMenuOpen] = useState(false); function handleModelsDialogChange(open: boolean) { setModelsDialogOpen(open); if (open) { @@ -985,6 +1012,15 @@ export default function HomeSidebar({ .catch((error) => { console.error('Failed to fetch releases:', error); }); + + getCloudServiceClientSync() + .getGitHubRepoInfo() + .then((info) => { + if (info?.repo?.stargazers_count != null) { + setStarCount(info.repo.stargazers_count); + } + }) + .catch(() => {}); }, []); // Update selected state + notify parent without navigating @@ -1122,6 +1158,19 @@ export default function HomeSidebar({ {/* Footer */} + {/* API Integration entry */} + + + setApiKeyDialogOpen(true)} + tooltip={t('common.apiIntegration')} + > + + {t('common.apiIntegration')} + + + + {/* Models entry */} @@ -1145,7 +1194,7 @@ export default function HomeSidebar({ {/* User menu using sidebar-07 nav-user DropdownMenu pattern */} - + {t('account.settings')} - setApiKeyDialogOpen(true)}> - - {t('common.apiIntegration')} - @@ -1266,6 +1311,29 @@ export default function HomeSidebar({ {t('common.featureRequest')} + { + window.open( + 'https://github.com/langbot-app/LangBot', + '_blank', + ); + }} + > + + {t('common.starOnGitHub')} + {starCount != null && ( + + {starCount >= 1000 + ? `${(starCount / 1000).toFixed(1)}k` + : starCount} + + )} + diff --git a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx index 0be3ff5b..08eb2ebc 100644 --- a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx +++ b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx @@ -34,10 +34,12 @@ export interface SidebarDataContextValue { pipelines: SidebarEntityItem[]; knowledgeBases: SidebarEntityItem[]; plugins: SidebarEntityItem[]; + mcpServers: SidebarEntityItem[]; refreshBots: () => Promise; refreshPipelines: () => Promise; refreshKnowledgeBases: () => Promise; refreshPlugins: () => Promise; + refreshMCPServers: () => Promise; refreshAll: () => Promise; // Breadcrumb: entity name shown when viewing a detail page detailEntityName: string | null; @@ -55,6 +57,7 @@ export function SidebarDataProvider({ const [pipelines, setPipelines] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); const [plugins, setPlugins] = useState([]); + const [mcpServers, setMCPServers] = useState([]); const [detailEntityName, setDetailEntityName] = useState(null); const refreshBots = useCallback(async () => { @@ -158,14 +161,36 @@ export function SidebarDataProvider({ } }, []); + const refreshMCPServers = useCallback(async () => { + try { + const resp = await httpClient.getMCPServers(); + setMCPServers( + resp.servers.map((server) => ({ + id: server.name, + name: server.name, + enabled: server.enable, + })), + ); + } catch (error) { + console.error('Failed to fetch MCP servers for sidebar:', error); + } + }, []); + const refreshAll = useCallback(async () => { await Promise.all([ refreshBots(), refreshPipelines(), refreshKnowledgeBases(), refreshPlugins(), + refreshMCPServers(), ]); - }, [refreshBots, refreshPipelines, refreshKnowledgeBases, refreshPlugins]); + }, [ + refreshBots, + refreshPipelines, + refreshKnowledgeBases, + refreshPlugins, + refreshMCPServers, + ]); // Fetch all entity lists on mount useEffect(() => { @@ -179,10 +204,12 @@ export function SidebarDataProvider({ pipelines, knowledgeBases, plugins, + mcpServers, refreshBots, refreshPipelines, refreshKnowledgeBases, refreshPlugins, + refreshMCPServers, refreshAll, detailEntityName, setDetailEntityName, diff --git a/web/src/app/home/mcp/MCPDetailContent.tsx b/web/src/app/home/mcp/MCPDetailContent.tsx new file mode 100644 index 00000000..7868bbe5 --- /dev/null +++ b/web/src/app/home/mcp/MCPDetailContent.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm'; +import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; +import { useTranslation } from 'react-i18next'; +import { Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +export default function MCPDetailContent({ id }: { id: string }) { + const isCreateMode = id === 'new'; + const router = useRouter(); + const { t } = useTranslation(); + const { refreshMCPServers, mcpServers, setDetailEntityName } = + useSidebarData(); + + // Set breadcrumb entity name + useEffect(() => { + if (isCreateMode) { + setDetailEntityName(t('mcp.createServer')); + } else { + const server = mcpServers.find((s) => s.id === id); + setDetailEntityName(server?.name ?? id); + } + return () => setDetailEntityName(null); + }, [id, isCreateMode, mcpServers, setDetailEntityName, t]); + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + // Track whether the form has unsaved changes + const [formDirty, setFormDirty] = useState(false); + + // Enable state managed here so the header switch works + const [serverEnabled, setServerEnabled] = useState(true); + const [enableLoaded, setEnableLoaded] = useState(false); + + // Fetch server enable state + useEffect(() => { + if (!isCreateMode) { + httpClient.getMCPServer(id).then((res) => { + const server = res.server ?? res; + setServerEnabled(server.enable ?? true); + setEnableLoaded(true); + }); + } + }, [id, isCreateMode]); + + const handleEnableToggle = useCallback( + async (checked: boolean) => { + const prev = serverEnabled; + setServerEnabled(checked); + try { + await httpClient.toggleMCPServer(id, checked); + refreshMCPServers(); + } catch { + setServerEnabled(prev); + toast.error(t('mcp.modifyFailed')); + } + }, + [id, serverEnabled, refreshMCPServers, t], + ); + + function handleFormSubmit() { + // Re-sync enable state after form save + httpClient.getMCPServer(id).then((res) => { + const server = res.server ?? res; + setServerEnabled(server.enable ?? true); + }); + refreshMCPServers(); + } + + function handleServerDeleted() { + refreshMCPServers(); + router.push('/home/mcp'); + } + + function handleNewServerCreated(serverName: string) { + refreshMCPServers(); + router.push(`/home/mcp?id=${encodeURIComponent(serverName)}`); + } + + function confirmDelete() { + httpClient + .deleteMCPServer(id) + .then(() => { + setShowDeleteConfirm(false); + toast.success(t('mcp.deleteSuccess')); + handleServerDeleted(); + }) + .catch((err) => { + toast.error(t('mcp.deleteFailed') + (err.msg || '')); + }); + } + + // Check extensions limit before creating + async function checkExtensionsLimit(): Promise { + const maxExtensions = systemInfo.limitation?.max_extensions ?? -1; + if (maxExtensions < 0) return true; + try { + const [pluginsResp, mcpResp] = await Promise.all([ + httpClient.getPlugins(), + httpClient.getMCPServers(), + ]); + const total = + (pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0); + if (total >= maxExtensions) { + toast.error( + t('limitation.maxExtensionsReached', { max: maxExtensions }), + ); + return false; + } + } catch { + // If we can't check, let backend handle it + } + return true; + } + + // ==================== Create Mode ==================== + if (isCreateMode) { + return ( +
+ {/* Header */} +
+

{t('mcp.createServer')}

+ +
+ + {/* Content */} +
+
+ +
+
+
+ ); + } + + // ==================== Edit Mode ==================== + return ( + <> +
+ {/* Header: title + enable switch + save button */} +
+
+

{t('mcp.editServer')}

+ {enableLoaded && ( +
+ + +
+ )} +
+ +
+ + {/* Content */} +
+
+ + + {/* Card: Danger Zone */} + + + + {t('mcp.dangerZone')} + + + {t('mcp.dangerZoneDescription')} + + + +
+
+

+ {t('mcp.deleteMCPAction')} +

+

+ {t('mcp.deleteMCPHint')} +

+
+ +
+
+
+
+
+
+ + {/* Delete confirmation dialog */} + + + + {t('mcp.confirmDeleteTitle')} + + {t('mcp.confirmDeleteServer')} + + +
{t('mcp.confirmDeleteServer')}
+ + + + +
+
+ + ); +} diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx new file mode 100644 index 00000000..fdf71c9c --- /dev/null +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -0,0 +1,913 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Resolver, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { + MCPServerRuntimeInfo, + MCPTool, + MCPServer, + MCPSessionStatus, + MCPServerExtraArgsSSE, + MCPServerExtraArgsHttp, + MCPServerExtraArgsStdio, +} from '@/app/infra/entities/api'; +import { CustomApiError } from '@/app/infra/entities/common'; + +// Status display for test / connecting / error states +function StatusDisplay({ + testing, + runtimeInfo, + t, +}: { + testing: boolean; + runtimeInfo: MCPServerRuntimeInfo; + t: (key: string) => string; +}) { + if (testing) { + return ( +
+ + + + + {t('mcp.testing')} +
+ ); + } + + if (runtimeInfo.status === MCPSessionStatus.CONNECTING) { + return ( +
+ + + + + {t('mcp.connecting')} +
+ ); + } + + return ( +
+
+ + + + {t('mcp.connectionFailed')} +
+ {runtimeInfo.error_message && ( +
+ {runtimeInfo.error_message} +
+ )} +
+ ); +} + +// Tools list component +function ToolsList({ tools }: { tools: MCPTool[] }) { + return ( +
+ {tools.map((tool, index) => ( + + + {tool.name} + {tool.description && ( + + {tool.description} + + )} + + + ))} +
+ ); +} + +const getFormSchema = (t: (key: string) => string) => + z + .object({ + name: z + .string({ required_error: t('mcp.nameRequired') }) + .min(1, { message: t('mcp.nameRequired') }), + mode: z.enum(['sse', 'stdio', 'http']), + timeout: z + .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) + .default(30), + ssereadtimeout: z + .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) + .default(300), + url: z.string().optional(), + command: z.string().optional(), + args: z.array(z.object({ value: z.string() })).optional(), + extra_args: z + .array( + z.object({ + key: z.string(), + type: z.enum(['string', 'number', 'boolean']), + value: z.string(), + }), + ) + .optional(), + }) + .superRefine((data, ctx) => { + if (data.mode === 'sse' || data.mode === 'http') { + if (!data.url || data.url.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('mcp.urlRequired'), + path: ['url'], + }); + } + } else if (data.mode === 'stdio') { + if (!data.command || data.command.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('mcp.commandRequired'), + path: ['command'], + }); + } + } + }); + +type FormValues = z.infer> & { + timeout: number; + ssereadtimeout: number; +}; + +interface MCPFormProps { + initServerName?: string; + onFormSubmit: () => void; + onNewServerCreated: (serverName: string) => void; + onDirtyChange?: (dirty: boolean) => void; +} + +export default function MCPForm({ + initServerName, + onFormSubmit, + onNewServerCreated, + onDirtyChange, +}: MCPFormProps) { + const { t } = useTranslation(); + const formSchema = getFormSchema(t); + const isEditMode = !!initServerName; + + const form = useForm({ + resolver: zodResolver(formSchema) as unknown as Resolver, + defaultValues: { + name: '', + mode: 'sse', + url: '', + command: '', + args: [], + timeout: 30, + ssereadtimeout: 300, + extra_args: [], + }, + }); + + // Track whether initial data loading is complete (to avoid marking form dirty) + const isInitializing = useRef(true); + + const [extraArgs, setExtraArgs] = useState< + { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] + >([]); + const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]); + const [mcpTesting, setMcpTesting] = useState(false); + const [runtimeInfo, setRuntimeInfo] = useState( + null, + ); + const pollingIntervalRef = useRef(null); + + const watchMode = form.watch('mode'); + + // Notify parent when dirty state changes + const { isDirty } = form.formState; + useEffect(() => { + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + + // Load server data + useEffect(() => { + isInitializing.current = true; + if (isEditMode && initServerName) { + loadServerForEdit(initServerName).finally(() => { + isInitializing.current = false; + }); + } else { + form.reset({ + name: '', + mode: 'sse', + url: '', + command: '', + args: [], + timeout: 30, + ssereadtimeout: 300, + extra_args: [], + }); + setExtraArgs([]); + setStdioArgs([]); + setRuntimeInfo(null); + isInitializing.current = false; + } + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [initServerName]); + + // Poll for updates when runtime_info status is CONNECTING + useEffect(() => { + if ( + !isEditMode || + !initServerName || + !runtimeInfo || + runtimeInfo.status !== MCPSessionStatus.CONNECTING + ) { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + return; + } + + if (!pollingIntervalRef.current) { + pollingIntervalRef.current = setInterval(() => { + loadServerForEdit(initServerName); + }, 3000); + } + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [isEditMode, initServerName, runtimeInfo?.status]); + + async function loadServerForEdit(serverName: string) { + try { + const resp = await httpClient.getMCPServer(serverName); + const server = resp.server ?? resp; + + const formValues: FormValues = { + name: server.name, + mode: server.mode, + url: '', + command: '', + args: [], + timeout: 30, + ssereadtimeout: 300, + extra_args: [], + }; + + let newExtraArgs: { + key: string; + type: 'string' | 'number' | 'boolean'; + value: string; + }[] = []; + let newStdioArgs: { value: string }[] = []; + + if (server.mode === 'sse' || server.mode === 'http') { + formValues.url = server.extra_args.url; + formValues.timeout = server.extra_args.timeout; + + if (server.mode === 'sse') { + formValues.ssereadtimeout = server.extra_args.ssereadtimeout; + } + + if (server.extra_args.headers) { + newExtraArgs = Object.entries(server.extra_args.headers).map( + ([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + }), + ); + formValues.extra_args = newExtraArgs; + } + } else if (server.mode === 'stdio') { + formValues.command = server.extra_args.command; + newStdioArgs = (server.extra_args.args || []).map((arg: string) => ({ + value: arg, + })); + formValues.args = newStdioArgs; + + if (server.extra_args.env) { + newExtraArgs = Object.entries(server.extra_args.env).map( + ([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + }), + ); + formValues.extra_args = newExtraArgs; + } + } + + setExtraArgs(newExtraArgs); + setStdioArgs(newStdioArgs); + + // Use form.reset so isDirty stays false after initial load + form.reset(formValues); + + if (server.runtime_info) { + setRuntimeInfo(server.runtime_info); + } else { + setRuntimeInfo(null); + } + } catch (error) { + console.error('Failed to load server:', error); + toast.error(t('mcp.loadFailed')); + } + } + + async function handleFormSubmit(value: z.infer) { + try { + let serverConfig: MCPServer; + + if (value.mode === 'sse' || value.mode === 'http') { + const headers: Record = {}; + value.extra_args?.forEach((arg) => { + headers[arg.key] = String(arg.value); + }); + + if (value.mode === 'sse') { + serverConfig = { + name: value.name, + mode: 'sse', + enable: true, + extra_args: { + url: value.url!, + headers: headers, + timeout: value.timeout, + ssereadtimeout: value.ssereadtimeout, + }, + }; + } else { + serverConfig = { + name: value.name, + mode: 'http', + enable: true, + extra_args: { + url: value.url!, + headers: headers, + timeout: value.timeout, + }, + }; + } + } else { + const env: Record = {}; + value.extra_args?.forEach((arg) => { + env[arg.key] = String(arg.value); + }); + const args = value.args?.map((arg) => arg.value) || []; + + serverConfig = { + name: value.name, + mode: 'stdio', + enable: true, + extra_args: { + command: value.command!, + args: args, + env: env, + }, + }; + } + + if (isEditMode && initServerName) { + await httpClient.updateMCPServer(initServerName, serverConfig); + toast.success(t('mcp.updateSuccess')); + // Reset dirty baseline to current values + form.reset(form.getValues()); + onFormSubmit(); + } else { + await httpClient.createMCPServer(serverConfig); + toast.success(t('mcp.createSuccess')); + onNewServerCreated(value.name); + } + } catch (error) { + console.error('Failed to save MCP server:', error); + const errMsg = (error as CustomApiError).msg || ''; + toast.error( + (isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')) + errMsg, + ); + } + } + + async function testMcp() { + setMcpTesting(true); + + try { + const mode = form.getValues('mode'); + let extraArgsData: + | MCPServerExtraArgsSSE + | MCPServerExtraArgsHttp + | MCPServerExtraArgsStdio; + + if (mode === 'sse') { + extraArgsData = { + url: form.getValues('url')!, + timeout: form.getValues('timeout'), + headers: Object.fromEntries( + extraArgs.map((arg) => [arg.key, arg.value]), + ), + ssereadtimeout: form.getValues('ssereadtimeout'), + }; + } else if (mode === 'http') { + extraArgsData = { + url: form.getValues('url')!, + timeout: form.getValues('timeout'), + headers: Object.fromEntries( + extraArgs.map((arg) => [arg.key, arg.value]), + ), + }; + } else { + extraArgsData = { + command: form.getValues('command')!, + args: stdioArgs.map((arg) => arg.value), + env: Object.fromEntries(extraArgs.map((arg) => [arg.key, arg.value])), + }; + } + + const { task_id } = await httpClient.testMCPServer('_', { + name: form.getValues('name'), + mode: mode, + enable: true, + extra_args: extraArgsData, + } as MCPServer); + + if (!task_id) { + throw new Error(t('mcp.noTaskId')); + } + + const interval = setInterval(async () => { + try { + const taskResp = await httpClient.getAsyncTask(task_id); + + if (taskResp.runtime?.done) { + clearInterval(interval); + setMcpTesting(false); + + if (taskResp.runtime.exception) { + const errorMsg = + taskResp.runtime.exception || t('mcp.unknownError'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + setRuntimeInfo({ + status: MCPSessionStatus.ERROR, + error_message: errorMsg, + tool_count: 0, + tools: [], + }); + } else { + if (isEditMode) { + await loadServerForEdit(form.getValues('name')); + } + toast.success(t('mcp.testSuccess')); + } + } + } catch (err) { + clearInterval(interval); + setMcpTesting(false); + const errorMsg = + (err as CustomApiError).msg || t('mcp.getTaskFailed'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + } + }, 1000); + } catch (err) { + setMcpTesting(false); + const errorMsg = (err as Error).message || t('mcp.unknownError'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + } + } + + const addExtraArg = () => { + const newArgs = [ + ...extraArgs, + { key: '', type: 'string' as const, value: '' }, + ]; + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs, { + shouldDirty: !isInitializing.current, + }); + }; + + const removeExtraArg = (index: number) => { + const newArgs = extraArgs.filter((_, i) => i !== index); + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs, { + shouldDirty: !isInitializing.current, + }); + }; + + const updateExtraArg = ( + index: number, + field: 'key' | 'type' | 'value', + value: string, + ) => { + const newArgs = [...extraArgs]; + newArgs[index] = { ...newArgs[index], [field]: value }; + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs, { + shouldDirty: !isInitializing.current, + }); + }; + + const addStdioArg = () => { + const newArgs = [...stdioArgs, { value: '' }]; + setStdioArgs(newArgs); + form.setValue('args', newArgs, { shouldDirty: !isInitializing.current }); + }; + + const removeStdioArg = (index: number) => { + const newArgs = stdioArgs.filter((_, i) => i !== index); + setStdioArgs(newArgs); + form.setValue('args', newArgs, { shouldDirty: !isInitializing.current }); + }; + + const updateStdioArg = (index: number, value: string) => { + const newArgs = [...stdioArgs]; + newArgs[index] = { value }; + setStdioArgs(newArgs); + form.setValue('args', newArgs, { shouldDirty: !isInitializing.current }); + }; + + return ( +
+ + {/* Runtime info: status + tools (edit mode only) */} + {isEditMode && runtimeInfo && ( + + + {t('mcp.title')} + + + {(mcpTesting || + runtimeInfo.status !== MCPSessionStatus.CONNECTED) && ( +
+ +
+ )} + + {!mcpTesting && + runtimeInfo.status === MCPSessionStatus.CONNECTED && + runtimeInfo.tools?.length > 0 && ( + <> +
+ {t('mcp.toolCount', { + count: runtimeInfo.tools?.length || 0, + })} +
+ + + )} + + +
+
+ )} + + {/* Server configuration */} + + + + {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} + + + {t('mcp.extraParametersDescription')} + + + + ( + + + {t('mcp.name')} + * + + + + + + + )} + /> + + ( + + {t('mcp.serverMode')} + + + + )} + /> + + {(watchMode === 'sse' || watchMode === 'http') && ( + <> + ( + + + {t('mcp.url')} + * + + + + + + + )} + /> + + ( + + {t('mcp.timeout')} + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> + + {watchMode === 'sse' && ( + ( + + {t('mcp.sseTimeout')} + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> + )} + + )} + + {watchMode === 'stdio' && ( + <> + ( + + + {t('mcp.command')} + * + + + + + + + )} + /> + + + {t('mcp.args')} +
+ {stdioArgs.map((arg, index) => ( +
+ + updateStdioArg(index, e.target.value) + } + /> + +
+ ))} + +
+
+ + )} + + + + {watchMode === 'sse' || watchMode === 'http' + ? t('mcp.headers') + : t('mcp.env')} + +
+ {extraArgs.map((arg, index) => ( +
+ + updateExtraArg(index, 'key', e.target.value) + } + /> + + updateExtraArg(index, 'value', e.target.value) + } + /> + +
+ ))} + +
+ + {t('mcp.extraParametersDescription')} + + +
+
+
+ + {/* Test button (create mode only, edit mode has it in the status card) */} + {!isEditMode && ( + + )} +
+ + ); +} diff --git a/web/src/app/home/mcp/page.tsx b/web/src/app/home/mcp/page.tsx index e1bb8162..d09d3a26 100644 --- a/web/src/app/home/mcp/page.tsx +++ b/web/src/app/home/mcp/page.tsx @@ -1,103 +1,21 @@ 'use client'; -import MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent'; -import MCPFormDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPFormDialog'; -import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog'; -import { Button } from '@/components/ui/button'; -import { PlusIcon } from 'lucide-react'; -import React, { useState } from 'react'; -import { httpClient } from '@/app/infra/http/HttpClient'; -import { systemInfo } from '@/app/infra/http/HttpClient'; -import { toast } from 'sonner'; +import { useSearchParams } from 'next/navigation'; import { useTranslation } from 'react-i18next'; +import MCPDetailContent from './MCPDetailContent'; export default function MCPPage() { const { t } = useTranslation(); - const [mcpFormOpen, setMcpFormOpen] = useState(false); - const [editingServerName, setEditingServerName] = useState( - null, - ); - const [isEditMode, setIsEditMode] = useState(false); - const [refreshKey, setRefreshKey] = useState(0); - const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); + const searchParams = useSearchParams(); + const detailId = searchParams.get('id'); - async function checkExtensionsLimit(): Promise { - const maxExtensions = systemInfo.limitation?.max_extensions ?? -1; - if (maxExtensions < 0) return true; - try { - const [pluginsResp, mcpResp] = await Promise.all([ - httpClient.getPlugins(), - httpClient.getMCPServers(), - ]); - const total = - (pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0); - if (total >= maxExtensions) { - toast.error( - t('limitation.maxExtensionsReached', { max: maxExtensions }), - ); - return false; - } - } catch { - // If we can't check, let backend handle it - } - return true; + if (detailId) { + return ; } return ( -
-
- -
- -
- { - setEditingServerName(serverName); - setIsEditMode(true); - setMcpFormOpen(true); - }} - /> -
- - { - setEditingServerName(null); - setIsEditMode(false); - setRefreshKey((prev) => prev + 1); - }} - onDelete={() => { - setShowDeleteConfirmModal(true); - }} - /> - - { - setMcpFormOpen(false); - setEditingServerName(null); - setIsEditMode(false); - setRefreshKey((prev) => prev + 1); - }} - /> +
+

{t('mcp.selectFromSidebar')}

); } diff --git a/web/src/app/infra/http/CloudServiceClient.ts b/web/src/app/infra/http/CloudServiceClient.ts index 1305f631..5c08e2ee 100644 --- a/web/src/app/infra/http/CloudServiceClient.ts +++ b/web/src/app/infra/http/CloudServiceClient.ts @@ -97,6 +97,10 @@ export class CloudServiceClient extends BaseHttpClient { return this.get('/api/v1/dist/info/releases'); } + public getGitHubRepoInfo(): Promise { + return this.get('/api/v1/dist/info/repo'); + } + public getAllTags(): Promise<{ tags: PluginTag[] }> { return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags'); } @@ -134,3 +138,13 @@ export interface GitHubRelease { prerelease: boolean; draft: boolean; } + +export interface GitHubRepoInfo { + repo: { + stargazers_count: number; + forks_count: number; + open_issues_count: number; + [key: string]: unknown; + }; + contributors: unknown[]; +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index dabb6ec6..b22ad8dd 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -29,6 +29,7 @@ const enUS = { language: 'Language', helpDocs: 'Get Help', featureRequest: 'Feature Request', + starOnGitHub: 'Star on GitHub', create: 'Create', edit: 'Edit', delete: 'Delete', @@ -612,6 +613,13 @@ const enUS = { sseTimeoutNonNegative: 'SSE timeout cannot be negative', updateSuccess: 'Updated successfully', updateFailed: 'Update failed: ', + selectFromSidebar: 'Select an MCP server from the sidebar', + dangerZone: 'Danger Zone', + dangerZoneDescription: + 'Irreversible and destructive actions for this MCP server.', + deleteMCPAction: 'Delete this MCP server', + deleteMCPHint: + 'Once deleted, this MCP server configuration cannot be recovered.', }, pipelines: { title: 'Pipelines', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index abad1674..730c1eae 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -30,6 +30,7 @@ language: '言語', helpDocs: 'ヘルプドキュメント', featureRequest: '機能リクエスト', + starOnGitHub: 'GitHubでStarする', create: '作成', edit: '編集', delete: '削除', @@ -613,6 +614,11 @@ sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません', updateSuccess: '更新に成功しました', updateFailed: '更新に失敗しました:', + selectFromSidebar: 'サイドバーからMCPサーバーを選択してください', + dangerZone: '危険ゾーン', + dangerZoneDescription: 'このMCPサーバーに対する不可逆的な操作です。', + deleteMCPAction: 'このMCPサーバーを削除', + deleteMCPHint: '削除すると、このMCPサーバーの設定は復元できません。', }, pipelines: { title: 'パイプライン', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 489679ab..b9ed1ccf 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -28,6 +28,7 @@ const zhHans = { language: '语言', helpDocs: '帮助文档', featureRequest: '需求建议', + starOnGitHub: '在 GitHub 上 Star', create: '创建', edit: '编辑', delete: '删除', @@ -584,6 +585,11 @@ const zhHans = { sseTimeoutNonNegative: 'SSE超时时间不能为负数', updateSuccess: '更新成功', updateFailed: '更新失败:', + selectFromSidebar: '从侧边栏选择一个 MCP 服务器', + dangerZone: '危险区域', + dangerZoneDescription: '此 MCP 服务器的不可逆操作。', + deleteMCPAction: '删除此 MCP 服务器', + deleteMCPHint: '删除后,此 MCP 服务器配置将无法恢复。', }, pipelines: { title: '流水线', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 3a08fe8e..1f5b1824 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -28,6 +28,7 @@ const zhHant = { language: '語言', helpDocs: '輔助說明', featureRequest: '需求建議', + starOnGitHub: '在 GitHub 上 Star', create: '建立', edit: '編輯', delete: '刪除', @@ -577,6 +578,11 @@ const zhHant = { sseTimeoutNonNegative: 'SSE逾時時間不能為負數', updateSuccess: '更新成功', updateFailed: '更新失敗:', + selectFromSidebar: '從側邊欄選擇一個 MCP 伺服器', + dangerZone: '危險區域', + dangerZoneDescription: '此 MCP 伺服器的不可逆操作。', + deleteMCPAction: '刪除此 MCP 伺服器', + deleteMCPHint: '刪除後,此 MCP 伺服器設定將無法恢復。', }, pipelines: { title: '流程線',