ConnectMcpServerDialog.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import * as React from "react";
  2. import { Braces, Plus, Trash2 } from "lucide-react";
  3. import { useTranslation } from "react-i18next";
  4. import { connectMcpServer } from "@/api";
  5. import { Button } from "@/components/ui/button";
  6. import { Dialog } from "@/components/ui/dialog";
  7. import { Input } from "@/components/ui/input";
  8. import { toast } from "@/components/ui/toaster";
  9. import type { JSONObject } from "@/types";
  10. interface ConnectMcpServerDialogProps {
  11. open: boolean;
  12. onOpenChange: (open: boolean) => void;
  13. onCreated: () => void;
  14. }
  15. export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: ConnectMcpServerDialogProps) {
  16. const { t } = useTranslation();
  17. const [serverName, setServerName] = React.useState("");
  18. const [url, setUrl] = React.useState("");
  19. const [rawConfig, setRawConfig] = React.useState("");
  20. const [headers, setHeaders] = React.useState<Array<{ key: string; value: string }>>([]);
  21. const [timeout, setTimeout] = React.useState("30");
  22. const [submitting, setSubmitting] = React.useState(false);
  23. function reset() {
  24. setServerName("");
  25. setUrl("");
  26. setRawConfig("");
  27. setHeaders([]);
  28. setTimeout("30");
  29. }
  30. function addHeader() {
  31. setHeaders([...headers, { key: "", value: "" }]);
  32. }
  33. function removeHeader(index: number) {
  34. setHeaders(headers.filter((_, i) => i !== index));
  35. }
  36. function updateHeader(index: number, field: "key" | "value", value: string) {
  37. const updated = [...headers];
  38. const current = updated[index];
  39. if (current) {
  40. updated[index] = { ...current, [field]: value };
  41. setHeaders(updated);
  42. }
  43. }
  44. async function submit(event: React.FormEvent) {
  45. event.preventDefault();
  46. const pastedConfig = parseConfig(rawConfig);
  47. if (rawConfig.trim() && !pastedConfig) {
  48. toast.error(t("tools.invalidMcpConfig"));
  49. return;
  50. }
  51. if (!pastedConfig && (!serverName.trim() || !url.trim())) return;
  52. const headersObj: Record<string, string> = {};
  53. for (const h of headers) {
  54. if (h.key.trim() && h.value.trim()) {
  55. headersObj[h.key.trim()] = h.value.trim();
  56. }
  57. }
  58. setSubmitting(true);
  59. try {
  60. await connectMcpServer({
  61. name: serverName.trim() || undefined,
  62. config: pastedConfig ?? {
  63. transport: "sse",
  64. server_name: serverName.trim(),
  65. url: url.trim(),
  66. headers: headersObj,
  67. timeout_seconds: Number(timeout) || null,
  68. },
  69. });
  70. toast.success(t("tools.mcpConnected"));
  71. onOpenChange(false);
  72. reset();
  73. onCreated();
  74. } catch (err) {
  75. toast.error(err instanceof Error ? err.message : t("tools.failedToConnectMcp"));
  76. } finally {
  77. setSubmitting(false);
  78. }
  79. }
  80. return (
  81. <Dialog open={open} onOpenChange={onOpenChange} title={t("tools.connectMcpServer")} className="max-w-lg">
  82. <form className="space-y-4" onSubmit={submit}>
  83. <Field label={t("tools.pasteMcpConfig")}>
  84. <textarea
  85. 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"
  86. value={rawConfig}
  87. onChange={(e) => setRawConfig(e.target.value)}
  88. placeholder={'{ "server_name": { "url": "http://host:9090/sse", "headers": { "X-MCP-API-KEY": "..." }, "timeout": 50 } }'}
  89. />
  90. <p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
  91. <Braces className="h-3 w-3" />
  92. {t("tools.pasteMcpConfigHint")}
  93. </p>
  94. </Field>
  95. <Field label={t("tools.serverName")} required>
  96. <Input
  97. value={serverName}
  98. onChange={(e) => setServerName(e.target.value)}
  99. placeholder={t("tools.serverNamePlaceholder")}
  100. required={!rawConfig.trim()}
  101. />
  102. </Field>
  103. <Field label={t("tools.sseEndpointUrl")} required>
  104. <Input
  105. value={url}
  106. onChange={(e) => setUrl(e.target.value)}
  107. placeholder="http://host:9090/sse"
  108. required={!rawConfig.trim()}
  109. />
  110. </Field>
  111. <div className="space-y-2">
  112. <div className="flex items-center justify-between">
  113. <span className="text-sm text-muted-foreground">{t("tools.headersOptional")}</span>
  114. <Button type="button" variant="ghost" size="sm" onClick={addHeader}>
  115. <Plus className="h-3 w-3 mr-1" /> {t("common.add")}
  116. </Button>
  117. </div>
  118. {headers.map((h, i) => (
  119. <div key={i} className="flex gap-2">
  120. <Input
  121. className="flex-1"
  122. value={h.key}
  123. onChange={(e) => updateHeader(i, "key", e.target.value)}
  124. placeholder={t("tools.headerKey")}
  125. />
  126. <Input
  127. className="flex-1"
  128. value={h.value}
  129. onChange={(e) => updateHeader(i, "value", e.target.value)}
  130. placeholder={t("tools.headerValue")}
  131. />
  132. <Button type="button" variant="ghost" size="sm" onClick={() => removeHeader(i)}>
  133. <Trash2 className="h-3 w-3" />
  134. </Button>
  135. </div>
  136. ))}
  137. </div>
  138. <Field label={t("tools.timeoutSeconds")}>
  139. <Input
  140. type="number"
  141. value={timeout}
  142. onChange={(e) => setTimeout(e.target.value)}
  143. placeholder="30"
  144. />
  145. </Field>
  146. <div className="flex justify-end gap-2">
  147. <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
  148. {t("common.cancel")}
  149. </Button>
  150. <Button disabled={submitting || (!rawConfig.trim() && (!serverName.trim() || !url.trim()))}>
  151. {submitting ? t("tools.connecting") : t("tools.connect")}
  152. </Button>
  153. </div>
  154. </form>
  155. </Dialog>
  156. );
  157. }
  158. function parseConfig(value: string): JSONObject | null {
  159. if (!value.trim()) return null;
  160. try {
  161. const parsed = JSON.parse(value);
  162. return parsed && typeof parsed === "object" && !Array.isArray(parsed)
  163. ? parsed as JSONObject
  164. : null;
  165. } catch {
  166. return null;
  167. }
  168. }
  169. function Field({ label, children, required }: { label: string; children: React.ReactNode; required?: boolean }) {
  170. return (
  171. <label className="block space-y-2 text-sm">
  172. <span className="text-muted-foreground">
  173. {label}
  174. {required && <span className="text-red-500 ml-0.5">*</span>}
  175. </span>
  176. {children}
  177. </label>
  178. );
  179. }