681 lines
22 KiB
TypeScript
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>
|
|
)
|
|
}
|