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
This commit is contained in:
Junyan Qin
2026-03-27 19:59:34 +08:00
parent 4902c1d3b2
commit 23fa47b07e
11 changed files with 1359 additions and 100 deletions

View File

@@ -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);
}
}

View File

@@ -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<EntityCategoryId, string> = {
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) && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
@@ -411,6 +419,15 @@ function NavItems({
/>
)}
</span>
) : isMCP ? (
<span
className={cn(
'size-2 shrink-0 rounded-full',
item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
) : null}
<span className="truncate">{item.name}</span>
</button>
@@ -447,7 +464,7 @@ function NavItems({
alt=""
className="size-4 rounded"
/>
{isBot && (
{(isBot || isMCP) && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
@@ -458,6 +475,15 @@ function NavItems({
/>
)}
</span>
) : isMCP ? (
<span
className={cn(
'size-2 shrink-0 rounded-full',
item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
) : null}
<span className="truncate">{item.name}</span>
{item.debug && (
@@ -914,7 +940,8 @@ export default function HomeSidebar({
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(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 */}
<SidebarFooter>
{/* API Integration entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setApiKeyDialogOpen(true)}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* Models entry */}
<SidebarMenu>
<SidebarMenuItem>
@@ -1145,7 +1194,7 @@ export default function HomeSidebar({
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenu open={userMenuOpen} onOpenChange={setUserMenuOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
@@ -1226,10 +1275,6 @@ export default function HomeSidebar({
<Settings />
{t('account.settings')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setApiKeyDialogOpen(true)}>
<KeyRound />
{t('common.apiIntegration')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
@@ -1266,6 +1311,29 @@ export default function HomeSidebar({
<Lightbulb />
{t('common.featureRequest')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
window.open(
'https://github.com/langbot-app/LangBot',
'_blank',
);
}}
>
<Star
className={cn(
'text-yellow-500',
userMenuOpen && 'animate-twinkle',
)}
/>
<span className="flex-1">{t('common.starOnGitHub')}</span>
{starCount != null && (
<Badge variant="secondary" className="ml-auto text-xs">
{starCount >= 1000
? `${(starCount / 1000).toFixed(1)}k`
: starCount}
</Badge>
)}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />

View File

@@ -34,10 +34,12 @@ export interface SidebarDataContextValue {
pipelines: SidebarEntityItem[];
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
mcpServers: SidebarEntityItem[];
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
refreshPlugins: () => Promise<void>;
refreshMCPServers: () => Promise<void>;
refreshAll: () => Promise<void>;
// Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null;
@@ -55,6 +57,7 @@ export function SidebarDataProvider({
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(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,

View File

@@ -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<boolean> {
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 (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('mcp.createServer')}</h1>
<Button
type="submit"
form="mcp-form"
onClick={async (e) => {
if (!(await checkExtensionsLimit())) {
e.preventDefault();
}
}}
>
{t('common.submit')}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<MCPForm
initServerName={undefined}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
/>
</div>
</div>
</div>
);
}
// ==================== Edit Mode ====================
return (
<>
<div className="flex h-full flex-col">
{/* Header: title + enable switch + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">{t('mcp.editServer')}</h1>
{enableLoaded && (
<div className="flex items-center gap-2">
<Switch
id="mcp-enable-switch"
checked={serverEnabled}
onCheckedChange={handleEnableToggle}
/>
<Label
htmlFor="mcp-enable-switch"
className="text-sm text-muted-foreground cursor-pointer"
>
{t('common.enable')}
</Label>
</div>
)}
</div>
<Button type="submit" form="mcp-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<MCPForm
initServerName={id}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
/>
{/* Card: Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('mcp.dangerZone')}
</CardTitle>
<CardDescription>
{t('mcp.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('mcp.deleteMCPAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('mcp.deleteMCPHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>
<DialogDescription className="sr-only">
{t('mcp.confirmDeleteServer')}
</DialogDescription>
</DialogHeader>
<div className="py-4">{t('mcp.confirmDeleteServer')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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 (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.testing')}</span>
</div>
);
}
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.connecting')}</span>
</div>
);
}
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-red-600">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">{t('mcp.connectionFailed')}</span>
</div>
{runtimeInfo.error_message && (
<div className="text-sm text-red-500 pl-7">
{runtimeInfo.error_message}
</div>
)}
</div>
);
}
// Tools list component
function ToolsList({ tools }: { tools: MCPTool[] }) {
return (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{tools.map((tool, index) => (
<Card key={index} className="py-3 shadow-none">
<CardHeader>
<CardTitle className="text-sm">{tool.name}</CardTitle>
{tool.description && (
<CardDescription className="text-xs">
{tool.description}
</CardDescription>
)}
</CardHeader>
</Card>
))}
</div>
);
}
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<ReturnType<typeof getFormSchema>> & {
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<FormValues>({
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
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<MCPServerRuntimeInfo | null>(
null,
);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(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<typeof formSchema>) {
try {
let serverConfig: MCPServer;
if (value.mode === 'sse' || value.mode === 'http') {
const headers: Record<string, string> = {};
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<string, string> = {};
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 (
<Form {...form}>
<form
id="mcp-form"
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-6"
>
{/* Runtime info: status + tools (edit mode only) */}
{isEditMode && runtimeInfo && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t('mcp.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(mcpTesting ||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
<div className="p-3 rounded-lg border">
<StatusDisplay
testing={mcpTesting}
runtimeInfo={runtimeInfo}
t={t}
/>
</div>
)}
{!mcpTesting &&
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
runtimeInfo.tools?.length > 0 && (
<>
<div className="text-sm font-medium">
{t('mcp.toolCount', {
count: runtimeInfo.tools?.length || 0,
})}
</div>
<ToolsList tools={runtimeInfo.tools} />
</>
)}
<Button
type="button"
variant="outline"
onClick={() => testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
</CardContent>
</Card>
)}
{/* Server configuration */}
<Card>
<CardHeader>
<CardTitle>
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
</CardTitle>
<CardDescription>
{t('mcp.extraParametersDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.serverMode')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('mcp.selectMode')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">{t('mcp.http')}</SelectItem>
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{(watchMode === 'sse' || watchMode === 'http') && (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.url')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.timeout')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'sse' && (
<FormField
control={form.control}
name="ssereadtimeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.sseTimeoutDescription')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{watchMode === 'stdio' && (
<>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.command')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t('mcp.args')}</FormLabel>
<div className="space-y-2">
{stdioArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('mcp.args')}
value={arg.value}
onChange={(e) =>
updateStdioArg(index, e.target.value)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-500 hover:text-red-600"
onClick={() => removeStdioArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z" />
</svg>
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={addStdioArg}
>
{t('mcp.addArgument')}
</Button>
</div>
</FormItem>
</>
)}
<FormItem>
<FormLabel>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.headers')
: t('mcp.env')}
</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-500 hover:text-red-600"
onClick={() => removeExtraArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z" />
</svg>
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.addHeader')
: t('mcp.addEnvVar')}
</Button>
</div>
<FormDescription>
{t('mcp.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
</CardContent>
</Card>
{/* Test button (create mode only, edit mode has it in the status card) */}
{!isEditMode && (
<Button
type="button"
variant="outline"
onClick={() => testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
)}
</form>
</Form>
);
}

View File

@@ -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<string | null>(
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<boolean> {
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 <MCPDetailContent id={detailId} />;
}
return (
<div className="h-full flex flex-col">
<div className="flex flex-row justify-end items-center px-[0.8rem] pb-4 flex-shrink-0">
<Button
variant="default"
className="px-6 py-4 cursor-pointer"
onClick={async () => {
if (!(await checkExtensionsLimit())) return;
setIsEditMode(false);
setEditingServerName(null);
setMcpFormOpen(true);
}}
>
<PlusIcon className="w-4 h-4" />
{t('mcp.add')}
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<MCPServerComponent
key={refreshKey}
onEditServer={(serverName) => {
setEditingServerName(serverName);
setIsEditMode(true);
setMcpFormOpen(true);
}}
/>
</div>
<MCPFormDialog
open={mcpFormOpen}
onOpenChange={setMcpFormOpen}
serverName={editingServerName}
isEditMode={isEditMode}
onSuccess={() => {
setEditingServerName(null);
setIsEditMode(false);
setRefreshKey((prev) => prev + 1);
}}
onDelete={() => {
setShowDeleteConfirmModal(true);
}}
/>
<MCPDeleteConfirmDialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
serverName={editingServerName}
onSuccess={() => {
setMcpFormOpen(false);
setEditingServerName(null);
setIsEditMode(false);
setRefreshKey((prev) => prev + 1);
}}
/>
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('mcp.selectFromSidebar')}</p>
</div>
);
}

View File

@@ -97,6 +97,10 @@ export class CloudServiceClient extends BaseHttpClient {
return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');
}
public getGitHubRepoInfo(): Promise<GitHubRepoInfo> {
return this.get<GitHubRepoInfo>('/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[];
}

View File

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

View File

@@ -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: 'パイプライン',

View File

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

View File

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