533 lines
25 KiB
TypeScript
533 lines
25 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useAppStore } from '@/stores/app';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { useI18n } from '@/i18n';
|
|
import { mcpApi, type McpServerFull, type McpServerPayload, type McpToolDef } from '@/api/endpoints/mcp';
|
|
import {
|
|
Plug, Plus, Pencil, Trash2, Check, X, Loader2, ChevronRight,
|
|
Power, PowerOff, Search, Wrench, Terminal, Globe, FileCode2, ChevronDown, ChevronUp,
|
|
} from 'lucide-react';
|
|
|
|
/* ── Msg ── */
|
|
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
|
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
|
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
|
return (
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
|
{type === 's' && <Check size={14} />}
|
|
{type === 'e' && <X size={14} />}
|
|
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
|
<span>{text}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Type badge ── */
|
|
function TypeBadge({ type }: { type: string }) {
|
|
const cfg: Record<string, { icon: React.ReactNode; bg: string; fg: string }> = {
|
|
stdio: { icon: <Terminal size={10} />, bg: 'color-mix(in srgb, var(--ac) 12%, transparent)', fg: 'var(--ac)' },
|
|
sse: { icon: <Globe size={10} />, bg: 'color-mix(in srgb, var(--gn) 12%, transparent)', fg: 'var(--gn)' },
|
|
module: { icon: <FileCode2 size={10} />, bg: 'color-mix(in srgb, #e67e22 12%, transparent)', fg: '#e67e22' },
|
|
};
|
|
const c = cfg[type] || cfg.stdio;
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold" style={{ background: c.bg, color: c.fg }}>
|
|
{c.icon} {type}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/* ── Main ── */
|
|
export default function McpServersPage() {
|
|
const { adbCfg } = useAppStore();
|
|
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
|
const { t } = useI18n();
|
|
|
|
const [servers, setServers] = useState<McpServerFull[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Form
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [editing, setEditing] = useState<McpServerFull | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
|
const formRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Form fields
|
|
const [sName, setSName] = useState('');
|
|
const [sDesc, setSDesc] = useState('');
|
|
const [sType, setSType] = useState<'stdio' | 'sse' | 'module'>('stdio');
|
|
const [sCmd, setSCmd] = useState('');
|
|
const [sArgs, setSArgs] = useState('');
|
|
const [sUrl, setSUrl] = useState('');
|
|
const [sAdb, setSAdb] = useState('');
|
|
|
|
// Table
|
|
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
|
const [toggling, setToggling] = useState<Record<string, boolean>>({});
|
|
const [discovering, setDiscovering] = useState<Record<string, boolean>>({});
|
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
|
|
|
const fetchServers = useCallback(async () => {
|
|
try {
|
|
const data = await mcpApi.list();
|
|
setServers(data);
|
|
} catch {} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { fetchServers(); }, [fetchServers]);
|
|
|
|
const resetForm = () => {
|
|
setEditing(null);
|
|
setFormOpen(false);
|
|
setSName('');
|
|
setSDesc('');
|
|
setSType('stdio');
|
|
setSCmd('');
|
|
setSArgs('');
|
|
setSUrl('');
|
|
setSAdb('');
|
|
setFormMsg(null);
|
|
setSaving(false);
|
|
};
|
|
|
|
const startEdit = (srv: McpServerFull) => {
|
|
setEditing(srv);
|
|
setSName(srv.name);
|
|
setSDesc(srv.description || '');
|
|
setSType((srv.server_type || 'stdio') as 'stdio' | 'sse' | 'module');
|
|
setSCmd(srv.command || '');
|
|
setSArgs(srv.args ? JSON.stringify(srv.args) : '');
|
|
setSUrl(srv.url || '');
|
|
setSAdb(srv.linked_adb_id || '');
|
|
setFormOpen(true);
|
|
setFormMsg(null);
|
|
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!sName.trim()) { setFormMsg({ type: 'e', text: t('mcp.fillName') }); return; }
|
|
|
|
let args: string[] | null = null;
|
|
if (sArgs.trim()) {
|
|
try { args = JSON.parse(sArgs); } catch { setFormMsg({ type: 'e', text: t('mcp.invalidArgs') }); return; }
|
|
}
|
|
|
|
const body: McpServerPayload = {
|
|
name: sName.trim(),
|
|
description: sDesc.trim() || null,
|
|
server_type: sType,
|
|
command: sType !== 'sse' ? (sCmd.trim() || null) : null,
|
|
url: sType === 'sse' ? (sUrl.trim() || null) : null,
|
|
args,
|
|
linked_adb_id: sAdb || null,
|
|
};
|
|
|
|
setSaving(true);
|
|
setFormMsg({ type: 'i', text: editing ? t('mcp.updating') : t('mcp.saving') });
|
|
|
|
try {
|
|
if (editing) {
|
|
await mcpApi.update(editing.id, body);
|
|
} else {
|
|
await mcpApi.create(body);
|
|
}
|
|
setFormMsg({ type: 's', text: editing ? t('mcp.updatedSuccess') : t('mcp.savedSuccess') });
|
|
await fetchServers();
|
|
useAppStore.getState().loadData();
|
|
setTimeout(resetForm, 1200);
|
|
} catch (err) {
|
|
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
setConfirmDelete(null);
|
|
setTableMsg({ type: 'i', text: t('mcp.deletingServer') });
|
|
try {
|
|
await mcpApi.remove(id);
|
|
await fetchServers();
|
|
useAppStore.getState().loadData();
|
|
setTableMsg({ type: 's', text: t('mcp.deletedSuccess') });
|
|
if (editing?.id === id) resetForm();
|
|
setTimeout(() => setTableMsg(null), 3000);
|
|
} catch (err) {
|
|
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
|
|
}
|
|
};
|
|
|
|
const handleToggle = async (id: string) => {
|
|
setToggling((p) => ({ ...p, [id]: true }));
|
|
try {
|
|
await mcpApi.toggle(id);
|
|
await fetchServers();
|
|
useAppStore.getState().loadData();
|
|
} catch (err) {
|
|
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
|
} finally {
|
|
setToggling((p) => ({ ...p, [id]: false }));
|
|
}
|
|
};
|
|
|
|
const handleDiscover = async (id: string) => {
|
|
setDiscovering((p) => ({ ...p, [id]: true }));
|
|
setTableMsg({ type: 'i', text: t('mcp.discovering') });
|
|
try {
|
|
const res = await mcpApi.discoverTools(id);
|
|
await fetchServers();
|
|
useAppStore.getState().loadData();
|
|
setTableMsg({ type: 's', text: t('mcp.discoveredTools').replace('{0}', String(res.discovered)).replace('{1}', String(res.total)) });
|
|
setExpandedTools((p) => ({ ...p, [id]: true }));
|
|
setTimeout(() => setTableMsg(null), 4000);
|
|
} catch (err) {
|
|
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('mcp.errorDiscoverTools') });
|
|
} finally {
|
|
setDiscovering((p) => ({ ...p, [id]: false }));
|
|
}
|
|
};
|
|
|
|
const handleRemoveTool = async (srvId: string, idx: number) => {
|
|
const srv = servers.find((s) => s.id === srvId);
|
|
if (!srv?.tools) return;
|
|
const tool = srv.tools[idx];
|
|
const toolName = typeof tool === 'string' ? tool : (tool as McpToolDef).name;
|
|
if (!confirm(t('mcp.removeToolConfirm').replace('{0}', toolName))) return;
|
|
const newTools = [...srv.tools];
|
|
newTools.splice(idx, 1);
|
|
try {
|
|
await mcpApi.updateTools(srvId, newTools);
|
|
await fetchServers();
|
|
useAppStore.getState().loadData();
|
|
} catch (err) {
|
|
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
|
}
|
|
};
|
|
|
|
const isFormVisible = formOpen || !!editing;
|
|
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
|
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
|
const labelCls = "block";
|
|
const labelStyle = {};
|
|
const hintCls = "text-[.64rem] mt-0.5";
|
|
const hintStyle = { color: 'var(--t4)' };
|
|
|
|
return (
|
|
<div className="page">
|
|
|
|
{/* Header */}
|
|
<div className="page-header">
|
|
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
|
<Plug size={24} style={{ color: 'var(--gn)' }} />
|
|
</div>
|
|
<div>
|
|
<h1>{t('mcp.title')}</h1>
|
|
<div className="subtitle">{t('mcp.subtitle')}</div>
|
|
</div>
|
|
<div className="spacer" />
|
|
<span className="count">
|
|
{servers.length} {servers.length === 1 ? 'server' : 'servers'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Server cards */}
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
|
<Plug size={16} style={{ color: 'var(--gn)' }} />
|
|
</div>
|
|
<h2>{t('mcp.registered')}</h2>
|
|
</div>
|
|
|
|
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
|
</div>
|
|
) : servers.length === 0 ? (
|
|
<div className="py-10 text-center">
|
|
<Plug size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
|
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('mcp.noServers')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col">
|
|
{servers.map((srv) => {
|
|
const tools: McpToolDef[] = Array.isArray(srv.tools) ? srv.tools : [];
|
|
const isExpanded = expandedTools[srv.id];
|
|
const adb = srv.linked_adb_id ? adbCfg.find((a) => a.id === srv.linked_adb_id) : null;
|
|
return (
|
|
<div
|
|
key={srv.id}
|
|
className="px-5 py-4 transition-colors"
|
|
style={{
|
|
borderBottom: '1px solid var(--bd)',
|
|
background: editing?.id === srv.id ? 'color-mix(in srgb, var(--gn) 5%, var(--bg1))' : undefined,
|
|
}}
|
|
>
|
|
{/* Header row */}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2.5">
|
|
<strong className="text-[.88rem]" style={{ color: 'var(--t1)' }}>{srv.name}</strong>
|
|
<TypeBadge type={srv.server_type} />
|
|
{(srv as any).is_global === 1 && (
|
|
<span className="px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>GLOBAL</span>
|
|
)}
|
|
<span
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.6rem] font-semibold"
|
|
style={{
|
|
background: srv.is_active ? 'var(--gnl)' : 'var(--rdl)',
|
|
color: srv.is_active ? 'var(--gn)' : 'var(--rd)',
|
|
}}
|
|
>
|
|
{srv.is_active ? <Power size={8} /> : <PowerOff size={8} />}
|
|
{srv.is_active ? t('mcp.active') : t('mcp.inactive')}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
{(isAdmin || !(srv as any).is_global) && (
|
|
<>
|
|
<button onClick={() => startEdit(srv)} className="btn btn-secondary btn-sm">
|
|
<Pencil size={10} /> {t('mcp.edit')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleToggle(srv.id)}
|
|
disabled={toggling[srv.id]}
|
|
className="btn btn-secondary btn-sm"
|
|
>
|
|
{toggling[srv.id] ? <Loader2 size={10} className="animate-spin" /> : srv.is_active ? <PowerOff size={10} /> : <Power size={10} />}
|
|
{srv.is_active ? t('mcp.deactivate') : t('mcp.activate')}
|
|
</button>
|
|
{confirmDelete === srv.id ? (
|
|
<div className="flex gap-1">
|
|
<button onClick={() => handleDelete(srv.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
|
<Check size={10} /> {t('common.yes')}
|
|
</button>
|
|
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
|
{t('common.no')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button onClick={() => setConfirmDelete(srv.id)} className="btn btn-danger btn-sm">
|
|
<Trash2 size={10} /> {t('mcp.delete')}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
{srv.description && (
|
|
<div className="text-[.72rem] mb-2" style={{ color: 'var(--t3)' }}>{srv.description}</div>
|
|
)}
|
|
|
|
{/* Details row */}
|
|
<div className="flex gap-4 flex-wrap text-[.72rem] mb-2" style={{ color: 'var(--t4)' }}>
|
|
{srv.command && (
|
|
<div>
|
|
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.command')}</span>{' '}
|
|
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.command}</code>
|
|
</div>
|
|
)}
|
|
{srv.url && (
|
|
<div>
|
|
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.url')}</span>{' '}
|
|
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.url}</code>
|
|
</div>
|
|
)}
|
|
{srv.module_path && (
|
|
<div>
|
|
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.module')}</span>{' '}
|
|
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.module_path}</code>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.adb')}</span>{' '}
|
|
{adb ? (
|
|
<span className="px-1.5 py-0.5 rounded text-[.6rem] font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>{adb.config_name}</span>
|
|
) : t('mcp.noAdb')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tools section */}
|
|
<div className="mt-2" style={{ borderTop: '1px solid var(--bd)', paddingTop: 8 }}>
|
|
<div
|
|
className="flex items-center gap-2 cursor-pointer select-none mb-1"
|
|
onClick={() => setExpandedTools((p) => ({ ...p, [srv.id]: !p[srv.id] }))}
|
|
>
|
|
<Wrench size={12} style={{ color: 'var(--t2)' }} />
|
|
<span className="text-[.74rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
|
{t('mcp.tools')} ({tools.length})
|
|
</span>
|
|
{isExpanded ? <ChevronUp size={12} style={{ color: 'var(--t4)' }} /> : <ChevronDown size={12} style={{ color: 'var(--t4)' }} />}
|
|
</div>
|
|
|
|
{isExpanded && tools.length > 0 && (
|
|
<div className="flex flex-col gap-1 mt-1">
|
|
{tools.map((tool, i) => (
|
|
<div key={i} className="flex items-start gap-2 px-3 py-1.5 rounded-md text-[.72rem]" style={{ background: 'var(--bg)', border: '1px solid var(--bd)' }}>
|
|
<div className="flex-1 min-w-0">
|
|
<strong style={{ color: 'var(--t1)' }}>{typeof tool === 'string' ? tool : tool.name}</strong>
|
|
{typeof tool === 'object' && tool.description && (
|
|
<span style={{ color: 'var(--t4)' }}> — {tool.description}</span>
|
|
)}
|
|
{typeof tool === 'object' && tool.input_schema && (tool.input_schema as { properties?: Record<string, unknown> }).properties && (
|
|
<div className="text-[.64rem] mt-0.5" style={{ color: 'var(--t4)' }}>
|
|
Params: {Object.keys((tool.input_schema as { properties: Record<string, unknown> }).properties).join(', ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => handleRemoveTool(srv.id, i)}
|
|
className="flex-shrink-0 px-1.5 py-0.5 rounded text-[.6rem] font-semibold transition-colors"
|
|
style={{ color: 'var(--rd)', background: 'color-mix(in srgb, var(--rd) 8%, transparent)' }}
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{isExpanded && tools.length === 0 && (
|
|
<div className="text-[.72rem] py-1" style={{ color: 'var(--t4)' }}>{t('mcp.noTools')}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-2 mt-3 flex-wrap">
|
|
<button
|
|
onClick={() => handleDiscover(srv.id)}
|
|
disabled={discovering[srv.id]}
|
|
className="btn btn-success btn-sm"
|
|
>
|
|
{discovering[srv.id] ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
|
{t('mcp.discoverTools')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
|
<div className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none" onClick={() => { if (!editing) setFormOpen(!formOpen); }}>
|
|
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
|
<div className="flex items-center gap-2">
|
|
{editing ? <Pencil size={14} style={{ color: 'var(--gn)' }} /> : <Plus size={14} style={{ color: 'var(--gn)' }} />}
|
|
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
|
{editing ? `${t('mcp.editServer')} — ${editing.name}` : t('mcp.newServer')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{isFormVisible && (
|
|
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
|
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
|
{editing
|
|
? <>{t('mcp.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
|
|
: t('mcp.configDesc')}
|
|
</p>
|
|
|
|
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
|
|
|
{/* Name + description */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelCls} style={labelStyle}>{t('mcp.name')}</label>
|
|
<input type="text" value={sName} onChange={(e) => setSName(e.target.value)} placeholder="CIS Benchmark Server" className={inputCls} style={inputStyle} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls} style={labelStyle}>{t('mcp.description')}</label>
|
|
<input type="text" value={sDesc} onChange={(e) => setSDesc(e.target.value)} placeholder="Executa checks CIS 3.0" className={inputCls} style={inputStyle} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Server type */}
|
|
<div>
|
|
<label className={labelCls} style={labelStyle}>{t('mcp.serverType')}</label>
|
|
<div className="flex gap-2 mt-1">
|
|
{(['stdio', 'sse', 'module'] as const).map((tp) => {
|
|
const icons = { stdio: <Terminal size={12} />, sse: <Globe size={12} />, module: <FileCode2 size={12} /> };
|
|
const labels = { stdio: 'stdio', sse: 'SSE (HTTP)', module: 'Python Module' };
|
|
const selected = sType === tp;
|
|
return (
|
|
<button
|
|
key={tp}
|
|
type="button"
|
|
onClick={() => setSType(tp)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all"
|
|
style={{
|
|
background: selected ? 'color-mix(in srgb, var(--gn) 15%, transparent)' : 'var(--bg2)',
|
|
color: selected ? 'var(--gn)' : 'var(--t4)',
|
|
border: `1.5px solid ${selected ? 'var(--gn)' : 'var(--bd)'}`,
|
|
}}
|
|
>
|
|
{icons[tp]} {labels[tp]}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Conditional fields */}
|
|
{sType !== 'sse' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelCls} style={labelStyle}>{t('mcp.commandLabel')}</label>
|
|
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.commandHint')}</p>
|
|
<input type="text" value={sCmd} onChange={(e) => setSCmd(e.target.value)} placeholder="python3 server.py" className={inputCls} style={inputStyle} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls} style={labelStyle}>{t('mcp.argsLabel')}</label>
|
|
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.argsHint')}</p>
|
|
<input type="text" value={sArgs} onChange={(e) => setSArgs(e.target.value)} placeholder='["--config", "/path/to/config"]' className={inputCls} style={{ ...inputStyle, fontFamily: 'var(--fm)', fontSize: '.72rem' }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{sType === 'sse' && (
|
|
<div>
|
|
<label className={labelCls} style={labelStyle}>{t('mcp.urlLabel')}</label>
|
|
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.urlHint')}</p>
|
|
<input type="text" value={sUrl} onChange={(e) => setSUrl(e.target.value)} placeholder="http://localhost:8001/sse" className={inputCls} style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ADB link */}
|
|
<div>
|
|
<label className={labelCls} style={labelStyle}>{t('mcp.linkAdb')}</label>
|
|
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.linkAdbHint')}</p>
|
|
<select value={sAdb} onChange={(e) => setSAdb(e.target.value)} className={inputCls} style={inputStyle}>
|
|
<option value="">{t('mcp.noAdb')}</option>
|
|
{adbCfg.map((a) => (
|
|
<option key={a.id} value={a.id}>{a.config_name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Buttons */}
|
|
<div className="flex gap-2 pt-1">
|
|
<button onClick={handleSave} disabled={saving} className="btn btn-success">
|
|
{saving && <Loader2 size={14} className="animate-spin" />}
|
|
{editing ? t('mcp.saveChanges') : t('mcp.saveServer')}
|
|
</button>
|
|
{editing && (
|
|
<button onClick={resetForm} className="btn btn-secondary">
|
|
{t('mcp.cancel')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|