import { useCallback, useEffect, useState } from 'react' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { KeyValueEditor } from '@/components/ui/key-value-editor' import { ScrollArea } from '@/components/ui/scroll-area' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { DynamicConfigForm } from '@/components/dynamic-form' import { RestartOverlay } from '@/components/restart-overlay' import { useToast } from '@/hooks/use-toast' import { getBotConfig, getBotConfigSchema, updateBotConfigSection } from '@/lib/config-api' import { fieldHooks } from '@/lib/field-hooks' import { RestartProvider, useRestart } from '@/lib/restart-context' import type { ConfigSchema } from '@/types/config-schema' import { Copy, Info, Plus, Power, Save, Server, Trash2 } from 'lucide-react' import { MCPRootItemsHook } from './config/bot/hooks' type ConfigSectionData = Record type MCPTransport = 'stdio' | 'streamable_http' interface MCPAuthorization { mode: 'none' | 'bearer' bearer_token: string } interface MCPServerConfig { name: string enabled: boolean transport: MCPTransport command: string args: string[] env: Record url: string headers: Record http_timeout_seconds: number read_timeout_seconds: number authorization: MCPAuthorization } const DEFAULT_MCP_SERVER: MCPServerConfig = { name: '', enabled: true, transport: 'stdio', command: '', args: [], env: {}, url: '', headers: {}, http_timeout_seconds: 30, read_timeout_seconds: 300, authorization: { mode: 'none', bearer_token: '', }, } function asStringMap(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {} } return Object.fromEntries( Object.entries(value as Record).map(([key, itemValue]) => [ key, String(itemValue ?? ''), ]), ) } function normalizeMCPServer(value: unknown, index: number): MCPServerConfig { const source = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} const auth = source.authorization && typeof source.authorization === 'object' && !Array.isArray(source.authorization) ? (source.authorization as Record) : {} const transport = source.transport === 'streamable_http' ? 'streamable_http' : 'stdio' return { ...DEFAULT_MCP_SERVER, name: typeof source.name === 'string' ? source.name : `mcp-server-${index + 1}`, enabled: typeof source.enabled === 'boolean' ? source.enabled : DEFAULT_MCP_SERVER.enabled, transport, command: typeof source.command === 'string' ? source.command : '', args: Array.isArray(source.args) ? source.args.map((item) => String(item ?? '')) : [], env: asStringMap(source.env), url: typeof source.url === 'string' ? source.url : '', headers: asStringMap(source.headers), http_timeout_seconds: typeof source.http_timeout_seconds === 'number' ? source.http_timeout_seconds : DEFAULT_MCP_SERVER.http_timeout_seconds, read_timeout_seconds: typeof source.read_timeout_seconds === 'number' ? source.read_timeout_seconds : DEFAULT_MCP_SERVER.read_timeout_seconds, authorization: { mode: auth.mode === 'bearer' ? 'bearer' : 'none', bearer_token: typeof auth.bearer_token === 'string' ? auth.bearer_token : '', }, } } function normalizeMCPServers(value: unknown): MCPServerConfig[] { if (!Array.isArray(value)) { return [] } return value.map((item, index) => normalizeMCPServer(item, index)) } function updateNestedValue( target: ConfigSectionData | null | undefined, pathSegments: string[], value: unknown ): ConfigSectionData { const currentTarget = target && typeof target === 'object' && !Array.isArray(target) ? target : {} const [currentPath, ...restPath] = pathSegments if (!currentPath) { return currentTarget } if (restPath.length === 0) { return { ...currentTarget, [currentPath]: value, } } return { ...currentTarget, [currentPath]: updateNestedValue(currentTarget[currentPath] as ConfigSectionData | undefined, restPath, value), } } function MCPServersBlockEditor({ servers, onChange, }: { servers: MCPServerConfig[] onChange: (servers: MCPServerConfig[]) => void }) { const updateServer = (index: number, patch: Partial) => { onChange(servers.map((server, serverIndex) => ( serverIndex === index ? { ...server, ...patch } : server ))) } const updateAuthorization = (index: number, patch: Partial) => { const server = servers[index] if (!server) { return } updateServer(index, { authorization: { ...server.authorization, ...patch, }, }) } const addServer = () => { onChange([ ...servers, { ...DEFAULT_MCP_SERVER, name: `mcp-server-${servers.length + 1}`, }, ]) } const duplicateServer = (index: number) => { const server = servers[index] if (!server) { return } const nextServer = { ...server, name: `${server.name || 'mcp-server'}-copy`, args: [...server.args], env: { ...server.env }, headers: { ...server.headers }, authorization: { ...server.authorization }, } onChange([ ...servers.slice(0, index + 1), nextServer, ...servers.slice(index + 1), ]) } const removeServer = (index: number) => { onChange(servers.filter((_, serverIndex) => serverIndex !== index)) } return (
MCP 服务 {servers.length} 个
这里会写入 mcp.servers。stdio 用命令启动本地服务,streamable_http 连接远程 MCP 端点。
{servers.length === 0 ? (
尚未配置 MCP 服务。添加一个服务后,MaiSaka 可以调用它暴露的工具。
) : ( servers.map((server, index) => (
updateServer(index, { enabled })} />
updateServer(index, { name: event.target.value })} placeholder="服务名称,必须唯一" className="h-8 font-medium" />
{server.enabled ? '启用' : '禁用'}
{server.transport === 'stdio' ? (
updateServer(index, { command: event.target.value })} placeholder="例如 uvx、npx、python" />
) : (
updateServer(index, { url: event.target.value })} placeholder="https://example.com/mcp" />
)}
{server.transport === 'stdio' ? (