jan/web-app/src/containers/dialogs/AddEditMCPServer.tsx
2025-09-17 09:35:36 +07:00

681 lines
22 KiB
TypeScript

import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
IconPlus,
IconTrash,
IconGripVertical,
IconCodeDots,
} from '@tabler/icons-react'
import { MCPServerConfig } from '@/hooks/useMCPServers'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
DndContext,
closestCenter,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
} from '@dnd-kit/core'
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/utils'
import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
// Sortable argument item component
function SortableArgItem({
id,
value,
onChange,
onRemove,
canRemove,
placeholder,
}: {
id: number
value: string
onChange: (value: string) => void
onRemove: () => void
canRemove: boolean
placeholder: string
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 mb-2',
isDragging ? 'z-10' : 'z-0'
)}
>
<div
{...attributes}
{...listeners}
className="size-6 cursor-move flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
>
<IconGripVertical size={18} className="text-main-view-fg/60" />
</div>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
{canRemove && (
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={onRemove}
>
<IconTrash size={18} className="text-destructive" />
</div>
)}
</div>
)
}
interface AddEditMCPServerProps {
open: boolean
onOpenChange: (open: boolean) => void
editingKey: string | null
initialData?: MCPServerConfig
onSave: (name: string, config: MCPServerConfig) => void
}
export default function AddEditMCPServer({
open,
onOpenChange,
editingKey,
initialData,
onSave,
}: AddEditMCPServerProps) {
const { t } = useTranslation()
const [serverName, setServerName] = useState('')
const [command, setCommand] = useState('')
const [args, setArgs] = useState<string[]>([''])
const [envKeys, setEnvKeys] = useState<string[]>([''])
const [envValues, setEnvValues] = useState<string[]>([''])
const [transportType, setTransportType] = useState<'stdio' | 'http' | 'sse'>(
'stdio'
)
const [url, setUrl] = useState('')
const [headerKeys, setHeaderKeys] = useState<string[]>([''])
const [headerValues, setHeaderValues] = useState<string[]>([''])
const [timeout, setTimeout] = useState('')
const [isToggled, setIsToggled] = useState(false)
const [jsonContent, setJsonContent] = useState('')
const [error, setError] = useState<string | null>(null)
// Reset form when modal opens/closes or editing key changes
useEffect(() => {
if (open && editingKey && initialData) {
setServerName(editingKey)
setCommand(initialData.command || '')
setUrl(initialData.url || '')
setTimeout(initialData.timeout ? initialData.timeout.toString() : '')
setArgs(initialData.args?.length > 0 ? initialData.args : [''])
setTransportType(initialData?.type || 'stdio')
// Initialize JSON content for toggle mode
try {
const jsonData = { [editingKey]: initialData }
setJsonContent(JSON.stringify(jsonData, null, 2))
} catch {
setJsonContent('')
}
if (initialData.env) {
// Convert env object to arrays of keys and values
const keys = Object.keys(initialData.env)
const values = keys.map((key) => initialData.env[key])
setEnvKeys(keys.length > 0 ? keys : [''])
setEnvValues(values.length > 0 ? values : [''])
}
if (initialData.headers) {
// Convert headers object to arrays of keys and values
const headerKeysList = Object.keys(initialData.headers)
const headerValuesList = headerKeysList.map(
(key) => initialData.headers![key]
)
setHeaderKeys(headerKeysList.length > 0 ? headerKeysList : [''])
setHeaderValues(headerValuesList.length > 0 ? headerValuesList : [''])
}
} else if (open) {
// Add mode - reset form
resetForm()
}
}, [open, editingKey, initialData])
const resetForm = () => {
setServerName('')
setCommand('')
setUrl('')
setTimeout('')
setArgs([''])
setEnvKeys([''])
setEnvValues([''])
setHeaderKeys([''])
setHeaderValues([''])
setTransportType('stdio')
setIsToggled(false)
setJsonContent('')
setError(null)
}
const handleAddArg = () => {
setArgs([...args, ''])
}
const handleRemoveArg = (index: number) => {
const newArgs = [...args]
newArgs.splice(index, 1)
setArgs(newArgs.length > 0 ? newArgs : [''])
}
const handleArgChange = (index: number, value: string) => {
const newArgs = [...args]
newArgs[index] = value
setArgs(newArgs)
}
const handleReorderArgs = (oldIndex: number, newIndex: number) => {
setArgs(arrayMove(args, oldIndex, newIndex))
}
// Sensors for drag and drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 5,
},
}),
useSensor(KeyboardSensor)
)
const handleAddEnv = () => {
setEnvKeys([...envKeys, ''])
setEnvValues([...envValues, ''])
}
const handleRemoveEnv = (index: number) => {
const newKeys = [...envKeys]
const newValues = [...envValues]
newKeys.splice(index, 1)
newValues.splice(index, 1)
setEnvKeys(newKeys.length > 0 ? newKeys : [''])
setEnvValues(newValues.length > 0 ? newValues : [''])
}
const handleEnvKeyChange = (index: number, value: string) => {
const newKeys = [...envKeys]
newKeys[index] = value
setEnvKeys(newKeys)
}
const handleEnvValueChange = (index: number, value: string) => {
const newValues = [...envValues]
newValues[index] = value
setEnvValues(newValues)
}
const handleAddHeader = () => {
setHeaderKeys([...headerKeys, ''])
setHeaderValues([...headerValues, ''])
}
const handleRemoveHeader = (index: number) => {
const newKeys = [...headerKeys]
const newValues = [...headerValues]
newKeys.splice(index, 1)
newValues.splice(index, 1)
setHeaderKeys(newKeys.length > 0 ? newKeys : [''])
setHeaderValues(newValues.length > 0 ? newValues : [''])
}
const handleHeaderKeyChange = (index: number, value: string) => {
const newKeys = [...headerKeys]
newKeys[index] = value
setHeaderKeys(newKeys)
}
const handleHeaderValueChange = (index: number, value: string) => {
const newValues = [...headerValues]
newValues[index] = value
setHeaderValues(newValues)
}
const handleSave = () => {
// Handle JSON mode
if (isToggled) {
try {
const parsedData = JSON.parse(jsonContent)
// Validate that it's an object with server configurations
if (typeof parsedData !== 'object' || parsedData === null) {
setError(t('mcp-servers:editJson.errorFormat'))
return
}
// Check if this looks like a server config object instead of the expected format
if (parsedData.command || parsedData.url) {
setError(t('mcp-servers:editJson.errorMissingServerNameKey'))
return
}
// For each server in the JSON, validate serverName and config
for (const [serverName, config] of Object.entries(parsedData)) {
const trimmedServerName = serverName.trim()
if (!trimmedServerName) {
setError(t('mcp-servers:editJson.errorServerName'))
return
}
// Validate the config object
const serverConfig = config as MCPServerConfig
// Validate type field if present
if (
serverConfig.type &&
!['stdio', 'http', 'sse'].includes(serverConfig.type)
) {
setError(
t('mcp-servers:editJson.errorInvalidType', {
serverName: trimmedServerName,
type: serverConfig.type,
})
)
return
}
onSave(trimmedServerName, serverConfig as MCPServerConfig)
}
onOpenChange(false)
resetForm()
setError(null)
return
} catch {
setError(t('mcp-servers:editJson.errorFormat'))
return
}
}
// Handle form mode
// Convert env arrays to object
const envObj: Record<string, string> = {}
envKeys.forEach((key, index) => {
const keyName = key.trim()
if (keyName !== '') {
envObj[keyName] = envValues[index]?.trim() || ''
}
})
// Convert headers arrays to object
const headersObj: Record<string, string> = {}
headerKeys.forEach((key, index) => {
const keyName = key.trim()
if (keyName !== '') {
headersObj[keyName] = headerValues[index]?.trim() || ''
}
})
// Filter out empty args
const filteredArgs = args.map((arg) => arg.trim()).filter((arg) => arg)
const config: MCPServerConfig = {
command: transportType === 'stdio' ? command.trim() : '',
args: transportType === 'stdio' ? filteredArgs : [],
env: transportType === 'stdio' ? envObj : {},
type: transportType,
...(transportType !== 'stdio' && {
url: url.trim(),
headers: Object.keys(headersObj).length > 0 ? headersObj : undefined,
timeout: timeout.trim() !== '' ? parseInt(timeout) : undefined,
}),
}
if (serverName.trim() !== '') {
onSave(serverName.trim(), config)
onOpenChange(false)
resetForm()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onInteractOutside={(e) => {
e.preventDefault()
}}
>
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>
{editingKey
? t('mcp-servers:editServer')
: t('mcp-servers:addServer')}
</span>
<div
className={cn(
'size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out',
isToggled && 'bg-main-view-fg/10 text-accent'
)}
title="Add server by JSON"
onClick={() => setIsToggled(!isToggled)}
>
<IconCodeDots className="h-5 w-5 cursor-pointer transition-colors duration-200" />
</div>
</DialogTitle>
</DialogHeader>
{isToggled ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">
{t('mcp-servers:editJson.placeholder')}
</label>
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
<CodeEditor
value={jsonContent}
language="json"
placeholder={`{
"serverName": {
"command": "command",
"args": ["arg1", "arg2"],
"env": {
"KEY": "value"
}
}
}`}
onChange={(e) => {
setJsonContent(e.target.value)
setError(null)
}}
onPaste={() => setError(null)}
style={{
fontFamily: 'ui-monospace',
backgroundColor: 'transparent',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
whiteSpace: 'pre-wrap',
}}
className="w-full !text-sm min-h-[300px]"
/>
</div>
{error && <div className="text-destructive text-sm">{error}</div>}
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">
{t('mcp-servers:serverName')}
</label>
<Input
value={serverName}
onChange={(e) => setServerName(e.target.value)}
placeholder={t('mcp-servers:enterServerName')}
autoFocus
/>
</div>
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">
Transport Type
</label>
<RadioGroup
value={transportType}
onValueChange={(value) =>
setTransportType(value as 'http' | 'sse')
}
className="flex gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="stdio" id="stdio" />
<label
htmlFor="stdio"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
STDIO
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="http" id="http" />
<label
htmlFor="http"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
HTTP
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="sse" id="sse" />
<label
htmlFor="sse"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
SSE
</label>
</div>
</RadioGroup>
</div>
{transportType === 'stdio' ? (
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">
{t('mcp-servers:command')}
</label>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder={t('mcp-servers:enterCommand')}
/>
</div>
) : (
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">URL</label>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="Enter URL"
/>
</div>
)}
{transportType === 'stdio' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm">
{t('mcp-servers:arguments')}
</label>
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={handleAddArg}
>
<IconPlus size={18} className="text-main-view-fg/60" />
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = parseInt(active.id.toString())
const newIndex = parseInt(over?.id.toString() || '0')
handleReorderArgs(oldIndex, newIndex)
}
}}
>
<SortableContext
items={args.map((_, index) => index)}
strategy={verticalListSortingStrategy}
>
{args.map((arg, index) => (
<SortableArgItem
key={index}
id={index}
value={arg}
onChange={(value) => handleArgChange(index, value)}
onRemove={() => handleRemoveArg(index)}
canRemove={args.length > 1}
placeholder={t('mcp-servers:argument', {
index: index + 1,
})}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
{transportType === 'stdio' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm">{t('mcp-servers:envVars')}</label>
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={handleAddEnv}
>
<IconPlus size={18} className="text-main-view-fg/60" />
</div>
</div>
{envKeys.map((key, index) => (
<div key={`env-${index}`} className="flex items-center gap-2">
<Input
value={key}
onChange={(e) =>
handleEnvKeyChange(index, e.target.value)
}
placeholder={t('mcp-servers:key')}
className="flex-1"
/>
<Input
value={envValues[index] || ''}
onChange={(e) =>
handleEnvValueChange(index, e.target.value)
}
placeholder={t('mcp-servers:value')}
className="flex-1"
/>
{envKeys.length > 1 && (
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={() => handleRemoveEnv(index)}
>
<IconTrash size={18} className="text-destructive" />
</div>
)}
</div>
))}
</div>
)}
{(transportType === 'http' || transportType === 'sse') && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm">Headers</label>
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={handleAddHeader}
>
<IconPlus size={18} className="text-main-view-fg/60" />
</div>
</div>
{headerKeys.map((key, index) => (
<div
key={`header-${index}`}
className="flex items-center gap-2"
>
<Input
value={key}
onChange={(e) =>
handleHeaderKeyChange(index, e.target.value)
}
placeholder="Header name"
className="flex-1"
/>
<Input
value={headerValues[index] || ''}
onChange={(e) =>
handleHeaderValueChange(index, e.target.value)
}
placeholder="Header value"
className="flex-1"
/>
{headerKeys.length > 1 && (
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={() => handleRemoveHeader(index)}
>
<IconTrash size={18} className="text-destructive" />
</div>
)}
</div>
))}
</div>
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">
Timeout (seconds)
</label>
<Input
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
placeholder="Enter timeout in seconds"
type="number"
/>
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button variant="link" onClick={() => onOpenChange(false)}>
{t('common:cancel')}
</Button>
<Button
onClick={handleSave}
disabled={!isToggled && serverName.trim() === ''}
>
{t('mcp-servers:save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}