| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191 |
- import * as React from "react";
- import { Braces, Plus, Trash2 } from "lucide-react";
- import { useTranslation } from "react-i18next";
- import { connectMcpServer } from "@/api";
- import { Button } from "@/components/ui/button";
- import { Dialog } from "@/components/ui/dialog";
- import { Input } from "@/components/ui/input";
- import { toast } from "@/components/ui/toaster";
- import type { JSONObject } from "@/types";
- interface ConnectMcpServerDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onCreated: () => void;
- }
- export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: ConnectMcpServerDialogProps) {
- const { t } = useTranslation();
- const [serverName, setServerName] = React.useState("");
- const [url, setUrl] = React.useState("");
- const [rawConfig, setRawConfig] = React.useState("");
- const [headers, setHeaders] = React.useState<Array<{ key: string; value: string }>>([]);
- const [timeout, setTimeout] = React.useState("30");
- const [submitting, setSubmitting] = React.useState(false);
- function reset() {
- setServerName("");
- setUrl("");
- setRawConfig("");
- setHeaders([]);
- setTimeout("30");
- }
- function addHeader() {
- setHeaders([...headers, { key: "", value: "" }]);
- }
- function removeHeader(index: number) {
- setHeaders(headers.filter((_, i) => i !== index));
- }
- function updateHeader(index: number, field: "key" | "value", value: string) {
- const updated = [...headers];
- const current = updated[index];
- if (current) {
- updated[index] = { ...current, [field]: value };
- setHeaders(updated);
- }
- }
- async function submit(event: React.FormEvent) {
- event.preventDefault();
- const pastedConfig = parseConfig(rawConfig);
- if (rawConfig.trim() && !pastedConfig) {
- toast.error(t("tools.invalidMcpConfig"));
- return;
- }
- if (!pastedConfig && (!serverName.trim() || !url.trim())) return;
- const headersObj: Record<string, string> = {};
- for (const h of headers) {
- if (h.key.trim() && h.value.trim()) {
- headersObj[h.key.trim()] = h.value.trim();
- }
- }
- setSubmitting(true);
- try {
- await connectMcpServer({
- name: serverName.trim() || undefined,
- config: pastedConfig ?? {
- transport: "sse",
- server_name: serverName.trim(),
- url: url.trim(),
- headers: headersObj,
- timeout_seconds: Number(timeout) || null,
- },
- });
- toast.success(t("tools.mcpConnected"));
- onOpenChange(false);
- reset();
- onCreated();
- } catch (err) {
- toast.error(err instanceof Error ? err.message : t("tools.failedToConnectMcp"));
- } finally {
- setSubmitting(false);
- }
- }
- return (
- <Dialog open={open} onOpenChange={onOpenChange} title={t("tools.connectMcpServer")} className="max-w-lg">
- <form className="space-y-4" onSubmit={submit}>
- <Field label={t("tools.pasteMcpConfig")}>
- <textarea
- className="min-h-28 w-full rounded-md border border-border bg-surface-elevated px-3 py-2 font-mono text-sm outline-none focus:border-primary/70 focus:ring-2 focus:ring-primary/20"
- value={rawConfig}
- onChange={(e) => setRawConfig(e.target.value)}
- placeholder={'{ "server_name": { "url": "http://host:9090/sse", "headers": { "X-MCP-API-KEY": "..." }, "timeout": 50 } }'}
- />
- <p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
- <Braces className="h-3 w-3" />
- {t("tools.pasteMcpConfigHint")}
- </p>
- </Field>
- <Field label={t("tools.serverName")} required>
- <Input
- value={serverName}
- onChange={(e) => setServerName(e.target.value)}
- placeholder={t("tools.serverNamePlaceholder")}
- required={!rawConfig.trim()}
- />
- </Field>
- <Field label={t("tools.sseEndpointUrl")} required>
- <Input
- value={url}
- onChange={(e) => setUrl(e.target.value)}
- placeholder="http://host:9090/sse"
- required={!rawConfig.trim()}
- />
- </Field>
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <span className="text-sm text-muted-foreground">{t("tools.headersOptional")}</span>
- <Button type="button" variant="ghost" size="sm" onClick={addHeader}>
- <Plus className="h-3 w-3 mr-1" /> {t("common.add")}
- </Button>
- </div>
- {headers.map((h, i) => (
- <div key={i} className="flex gap-2">
- <Input
- className="flex-1"
- value={h.key}
- onChange={(e) => updateHeader(i, "key", e.target.value)}
- placeholder={t("tools.headerKey")}
- />
- <Input
- className="flex-1"
- value={h.value}
- onChange={(e) => updateHeader(i, "value", e.target.value)}
- placeholder={t("tools.headerValue")}
- />
- <Button type="button" variant="ghost" size="sm" onClick={() => removeHeader(i)}>
- <Trash2 className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- <Field label={t("tools.timeoutSeconds")}>
- <Input
- type="number"
- value={timeout}
- onChange={(e) => setTimeout(e.target.value)}
- placeholder="30"
- />
- </Field>
- <div className="flex justify-end gap-2">
- <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
- {t("common.cancel")}
- </Button>
- <Button disabled={submitting || (!rawConfig.trim() && (!serverName.trim() || !url.trim()))}>
- {submitting ? t("tools.connecting") : t("tools.connect")}
- </Button>
- </div>
- </form>
- </Dialog>
- );
- }
- function parseConfig(value: string): JSONObject | null {
- if (!value.trim()) return null;
- try {
- const parsed = JSON.parse(value);
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
- ? parsed as JSONObject
- : null;
- } catch {
- return null;
- }
- }
- function Field({ label, children, required }: { label: string; children: React.ReactNode; required?: boolean }) {
- return (
- <label className="block space-y-2 text-sm">
- <span className="text-muted-foreground">
- {label}
- {required && <span className="text-red-500 ml-0.5">*</span>}
- </span>
- {children}
- </label>
- );
- }
|