mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-28 00:14:21 +00:00
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:
@@ -114,6 +114,7 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--animate-twinkle: twinkle 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -158,3 +159,23 @@
|
|||||||
@apply bg-background text-foreground;
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Settings,
|
Settings,
|
||||||
|
Star,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -115,6 +116,7 @@ const ENTITY_CATEGORY_IDS = [
|
|||||||
'pipelines',
|
'pipelines',
|
||||||
'knowledge',
|
'knowledge',
|
||||||
'plugins',
|
'plugins',
|
||||||
|
'mcp',
|
||||||
] as const;
|
] as const;
|
||||||
type EntityCategoryId = (typeof ENTITY_CATEGORY_IDS)[number];
|
type EntityCategoryId = (typeof ENTITY_CATEGORY_IDS)[number];
|
||||||
|
|
||||||
@@ -124,6 +126,7 @@ const DETAIL_PAGE_CATEGORIES: EntityCategoryId[] = [
|
|||||||
'pipelines',
|
'pipelines',
|
||||||
'knowledge',
|
'knowledge',
|
||||||
'plugins',
|
'plugins',
|
||||||
|
'mcp',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Categories that support creating new entities from the sidebar
|
// Categories that support creating new entities from the sidebar
|
||||||
@@ -131,6 +134,7 @@ const CREATABLE_CATEGORIES: EntityCategoryId[] = [
|
|||||||
'bots',
|
'bots',
|
||||||
'pipelines',
|
'pipelines',
|
||||||
'knowledge',
|
'knowledge',
|
||||||
|
'mcp',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Categories where clicking the parent only toggles collapse (no list page)
|
// Categories where clicking the parent only toggles collapse (no list page)
|
||||||
@@ -138,6 +142,7 @@ const COLLAPSIBLE_ONLY_CATEGORIES: EntityCategoryId[] = [
|
|||||||
'bots',
|
'bots',
|
||||||
'pipelines',
|
'pipelines',
|
||||||
'knowledge',
|
'knowledge',
|
||||||
|
'mcp',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isEntityCategory(id: string): id is EntityCategoryId {
|
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
|
// Map sidebar config IDs to SidebarDataContext keys
|
||||||
const ENTITY_KEY_MAP: Record<
|
const ENTITY_KEY_MAP: Record<
|
||||||
EntityCategoryId,
|
EntityCategoryId,
|
||||||
'bots' | 'pipelines' | 'knowledgeBases' | 'plugins'
|
'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers'
|
||||||
> = {
|
> = {
|
||||||
bots: 'bots',
|
bots: 'bots',
|
||||||
pipelines: 'pipelines',
|
pipelines: 'pipelines',
|
||||||
knowledge: 'knowledgeBases',
|
knowledge: 'knowledgeBases',
|
||||||
plugins: 'plugins',
|
plugins: 'plugins',
|
||||||
|
mcp: 'mcpServers',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Route prefix map for entity detail pages
|
// Route prefix map for entity detail pages
|
||||||
@@ -161,6 +167,7 @@ const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
|
|||||||
pipelines: '/home/pipelines',
|
pipelines: '/home/pipelines',
|
||||||
knowledge: '/home/knowledge',
|
knowledge: '/home/knowledge',
|
||||||
plugins: '/home/plugins',
|
plugins: '/home/plugins',
|
||||||
|
mcp: '/home/mcp',
|
||||||
};
|
};
|
||||||
|
|
||||||
// localStorage key for collapsible section open/closed state
|
// localStorage key for collapsible section open/closed state
|
||||||
@@ -324,6 +331,7 @@ function NavItems({
|
|||||||
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id);
|
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id);
|
||||||
const isPlugin = config.id === 'plugins';
|
const isPlugin = config.id === 'plugins';
|
||||||
const isBot = config.id === 'bots';
|
const isBot = config.id === 'bots';
|
||||||
|
const isMCP = config.id === 'mcp';
|
||||||
const isActive =
|
const isActive =
|
||||||
selectedChild?.id === config.id ||
|
selectedChild?.id === config.id ||
|
||||||
pathname === routePrefix ||
|
pathname === routePrefix ||
|
||||||
@@ -400,7 +408,7 @@ function NavItems({
|
|||||||
alt=""
|
alt=""
|
||||||
className="size-4 rounded"
|
className="size-4 rounded"
|
||||||
/>
|
/>
|
||||||
{isBot && (
|
{(isBot || isMCP) && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
|
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
|
||||||
@@ -411,6 +419,15 @@ function NavItems({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
) : isMCP ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-2 shrink-0 rounded-full',
|
||||||
|
item.enabled === false
|
||||||
|
? 'bg-muted-foreground/40'
|
||||||
|
: 'bg-green-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -447,7 +464,7 @@ function NavItems({
|
|||||||
alt=""
|
alt=""
|
||||||
className="size-4 rounded"
|
className="size-4 rounded"
|
||||||
/>
|
/>
|
||||||
{isBot && (
|
{(isBot || isMCP) && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
|
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
|
||||||
@@ -458,6 +475,15 @@ function NavItems({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
) : isMCP ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-2 shrink-0 rounded-full',
|
||||||
|
item.enabled === false
|
||||||
|
? 'bg-muted-foreground/40'
|
||||||
|
: 'bg-green-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
{item.debug && (
|
{item.debug && (
|
||||||
@@ -914,7 +940,8 @@ export default function HomeSidebar({
|
|||||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||||
const [userEmail, setUserEmail] = useState<string>('');
|
const [userEmail, setUserEmail] = useState<string>('');
|
||||||
|
const [starCount, setStarCount] = useState<number | null>(null);
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
function handleModelsDialogChange(open: boolean) {
|
function handleModelsDialogChange(open: boolean) {
|
||||||
setModelsDialogOpen(open);
|
setModelsDialogOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -985,6 +1012,15 @@ export default function HomeSidebar({
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to fetch releases:', 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
|
// Update selected state + notify parent without navigating
|
||||||
@@ -1122,6 +1158,19 @@ export default function HomeSidebar({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<SidebarFooter>
|
<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 */}
|
{/* Models entry */}
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
@@ -1145,7 +1194,7 @@ export default function HomeSidebar({
|
|||||||
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
|
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<DropdownMenu open={userMenuOpen} onOpenChange={setUserMenuOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -1226,10 +1275,6 @@ export default function HomeSidebar({
|
|||||||
<Settings />
|
<Settings />
|
||||||
{t('account.settings')}
|
{t('account.settings')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setApiKeyDialogOpen(true)}>
|
|
||||||
<KeyRound />
|
|
||||||
{t('common.apiIntegration')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
@@ -1266,6 +1311,29 @@ export default function HomeSidebar({
|
|||||||
<Lightbulb />
|
<Lightbulb />
|
||||||
{t('common.featureRequest')}
|
{t('common.featureRequest')}
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ export interface SidebarDataContextValue {
|
|||||||
pipelines: SidebarEntityItem[];
|
pipelines: SidebarEntityItem[];
|
||||||
knowledgeBases: SidebarEntityItem[];
|
knowledgeBases: SidebarEntityItem[];
|
||||||
plugins: SidebarEntityItem[];
|
plugins: SidebarEntityItem[];
|
||||||
|
mcpServers: SidebarEntityItem[];
|
||||||
refreshBots: () => Promise<void>;
|
refreshBots: () => Promise<void>;
|
||||||
refreshPipelines: () => Promise<void>;
|
refreshPipelines: () => Promise<void>;
|
||||||
refreshKnowledgeBases: () => Promise<void>;
|
refreshKnowledgeBases: () => Promise<void>;
|
||||||
refreshPlugins: () => Promise<void>;
|
refreshPlugins: () => Promise<void>;
|
||||||
|
refreshMCPServers: () => Promise<void>;
|
||||||
refreshAll: () => Promise<void>;
|
refreshAll: () => Promise<void>;
|
||||||
// Breadcrumb: entity name shown when viewing a detail page
|
// Breadcrumb: entity name shown when viewing a detail page
|
||||||
detailEntityName: string | null;
|
detailEntityName: string | null;
|
||||||
@@ -55,6 +57,7 @@ export function SidebarDataProvider({
|
|||||||
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||||
|
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||||
|
|
||||||
const refreshBots = useCallback(async () => {
|
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 () => {
|
const refreshAll = useCallback(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshBots(),
|
refreshBots(),
|
||||||
refreshPipelines(),
|
refreshPipelines(),
|
||||||
refreshKnowledgeBases(),
|
refreshKnowledgeBases(),
|
||||||
refreshPlugins(),
|
refreshPlugins(),
|
||||||
|
refreshMCPServers(),
|
||||||
]);
|
]);
|
||||||
}, [refreshBots, refreshPipelines, refreshKnowledgeBases, refreshPlugins]);
|
}, [
|
||||||
|
refreshBots,
|
||||||
|
refreshPipelines,
|
||||||
|
refreshKnowledgeBases,
|
||||||
|
refreshPlugins,
|
||||||
|
refreshMCPServers,
|
||||||
|
]);
|
||||||
|
|
||||||
// Fetch all entity lists on mount
|
// Fetch all entity lists on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -179,10 +204,12 @@ export function SidebarDataProvider({
|
|||||||
pipelines,
|
pipelines,
|
||||||
knowledgeBases,
|
knowledgeBases,
|
||||||
plugins,
|
plugins,
|
||||||
|
mcpServers,
|
||||||
refreshBots,
|
refreshBots,
|
||||||
refreshPipelines,
|
refreshPipelines,
|
||||||
refreshKnowledgeBases,
|
refreshKnowledgeBases,
|
||||||
refreshPlugins,
|
refreshPlugins,
|
||||||
|
refreshMCPServers,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
detailEntityName,
|
detailEntityName,
|
||||||
setDetailEntityName,
|
setDetailEntityName,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,103 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent';
|
import { useSearchParams } from 'next/navigation';
|
||||||
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 { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import MCPDetailContent from './MCPDetailContent';
|
||||||
|
|
||||||
export default function MCPPage() {
|
export default function MCPPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [mcpFormOpen, setMcpFormOpen] = useState(false);
|
const searchParams = useSearchParams();
|
||||||
const [editingServerName, setEditingServerName] = useState<string | null>(
|
const detailId = searchParams.get('id');
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
|
||||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
|
|
||||||
|
|
||||||
async function checkExtensionsLimit(): Promise<boolean> {
|
if (detailId) {
|
||||||
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
|
return <MCPDetailContent id={detailId} />;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
<div className="flex flex-row justify-end items-center px-[0.8rem] pb-4 flex-shrink-0">
|
<p>{t('mcp.selectFromSidebar')}</p>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ export class CloudServiceClient extends BaseHttpClient {
|
|||||||
return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');
|
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[] }> {
|
public getAllTags(): Promise<{ tags: PluginTag[] }> {
|
||||||
return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');
|
return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');
|
||||||
}
|
}
|
||||||
@@ -134,3 +138,13 @@ export interface GitHubRelease {
|
|||||||
prerelease: boolean;
|
prerelease: boolean;
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitHubRepoInfo {
|
||||||
|
repo: {
|
||||||
|
stargazers_count: number;
|
||||||
|
forks_count: number;
|
||||||
|
open_issues_count: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
contributors: unknown[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const enUS = {
|
|||||||
language: 'Language',
|
language: 'Language',
|
||||||
helpDocs: 'Get Help',
|
helpDocs: 'Get Help',
|
||||||
featureRequest: 'Feature Request',
|
featureRequest: 'Feature Request',
|
||||||
|
starOnGitHub: 'Star on GitHub',
|
||||||
create: 'Create',
|
create: 'Create',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
@@ -612,6 +613,13 @@ const enUS = {
|
|||||||
sseTimeoutNonNegative: 'SSE timeout cannot be negative',
|
sseTimeoutNonNegative: 'SSE timeout cannot be negative',
|
||||||
updateSuccess: 'Updated successfully',
|
updateSuccess: 'Updated successfully',
|
||||||
updateFailed: 'Update failed: ',
|
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: {
|
pipelines: {
|
||||||
title: 'Pipelines',
|
title: 'Pipelines',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
language: '言語',
|
language: '言語',
|
||||||
helpDocs: 'ヘルプドキュメント',
|
helpDocs: 'ヘルプドキュメント',
|
||||||
featureRequest: '機能リクエスト',
|
featureRequest: '機能リクエスト',
|
||||||
|
starOnGitHub: 'GitHubでStarする',
|
||||||
create: '作成',
|
create: '作成',
|
||||||
edit: '編集',
|
edit: '編集',
|
||||||
delete: '削除',
|
delete: '削除',
|
||||||
@@ -613,6 +614,11 @@
|
|||||||
sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません',
|
sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません',
|
||||||
updateSuccess: '更新に成功しました',
|
updateSuccess: '更新に成功しました',
|
||||||
updateFailed: '更新に失敗しました:',
|
updateFailed: '更新に失敗しました:',
|
||||||
|
selectFromSidebar: 'サイドバーからMCPサーバーを選択してください',
|
||||||
|
dangerZone: '危険ゾーン',
|
||||||
|
dangerZoneDescription: 'このMCPサーバーに対する不可逆的な操作です。',
|
||||||
|
deleteMCPAction: 'このMCPサーバーを削除',
|
||||||
|
deleteMCPHint: '削除すると、このMCPサーバーの設定は復元できません。',
|
||||||
},
|
},
|
||||||
pipelines: {
|
pipelines: {
|
||||||
title: 'パイプライン',
|
title: 'パイプライン',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const zhHans = {
|
|||||||
language: '语言',
|
language: '语言',
|
||||||
helpDocs: '帮助文档',
|
helpDocs: '帮助文档',
|
||||||
featureRequest: '需求建议',
|
featureRequest: '需求建议',
|
||||||
|
starOnGitHub: '在 GitHub 上 Star',
|
||||||
create: '创建',
|
create: '创建',
|
||||||
edit: '编辑',
|
edit: '编辑',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
@@ -584,6 +585,11 @@ const zhHans = {
|
|||||||
sseTimeoutNonNegative: 'SSE超时时间不能为负数',
|
sseTimeoutNonNegative: 'SSE超时时间不能为负数',
|
||||||
updateSuccess: '更新成功',
|
updateSuccess: '更新成功',
|
||||||
updateFailed: '更新失败:',
|
updateFailed: '更新失败:',
|
||||||
|
selectFromSidebar: '从侧边栏选择一个 MCP 服务器',
|
||||||
|
dangerZone: '危险区域',
|
||||||
|
dangerZoneDescription: '此 MCP 服务器的不可逆操作。',
|
||||||
|
deleteMCPAction: '删除此 MCP 服务器',
|
||||||
|
deleteMCPHint: '删除后,此 MCP 服务器配置将无法恢复。',
|
||||||
},
|
},
|
||||||
pipelines: {
|
pipelines: {
|
||||||
title: '流水线',
|
title: '流水线',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const zhHant = {
|
|||||||
language: '語言',
|
language: '語言',
|
||||||
helpDocs: '輔助說明',
|
helpDocs: '輔助說明',
|
||||||
featureRequest: '需求建議',
|
featureRequest: '需求建議',
|
||||||
|
starOnGitHub: '在 GitHub 上 Star',
|
||||||
create: '建立',
|
create: '建立',
|
||||||
edit: '編輯',
|
edit: '編輯',
|
||||||
delete: '刪除',
|
delete: '刪除',
|
||||||
@@ -577,6 +578,11 @@ const zhHant = {
|
|||||||
sseTimeoutNonNegative: 'SSE逾時時間不能為負數',
|
sseTimeoutNonNegative: 'SSE逾時時間不能為負數',
|
||||||
updateSuccess: '更新成功',
|
updateSuccess: '更新成功',
|
||||||
updateFailed: '更新失敗:',
|
updateFailed: '更新失敗:',
|
||||||
|
selectFromSidebar: '從側邊欄選擇一個 MCP 伺服器',
|
||||||
|
dangerZone: '危險區域',
|
||||||
|
dangerZoneDescription: '此 MCP 伺服器的不可逆操作。',
|
||||||
|
deleteMCPAction: '刪除此 MCP 伺服器',
|
||||||
|
deleteMCPHint: '刪除後,此 MCP 伺服器設定將無法恢復。',
|
||||||
},
|
},
|
||||||
pipelines: {
|
pipelines: {
|
||||||
title: '流程線',
|
title: '流程線',
|
||||||
|
|||||||
Reference in New Issue
Block a user