// ============================================================================ // McpAddModal — admin-side "create MCP catalog entry" 表单(PR-S.16) // ============================================================================ // // 取代 manage-app.jsx 里原 `window.prompt` 简陋的 addMCP() 入口。让 admin // 一次填全所有字段(server_id / name / description / transport / 连接细节 // / auth_env_key),POST /v1/mcp/servers 创建好后直接选中。 // // 字段语义(跟 backend CreateMcpServerRequest 一致,见 // src/baizhi_agent_runtime/app/api.py:199): // - server_id: required, immutable, primary key。用 kebab-case 习惯 // - name: display name(可改;sane default = server_id) // - description: 给 marketplace listing / Tooltip 用 // - transport: http | stdio | sse // - http_url: transport=http/sse 用 // - headers_template: request headers template,`${VAR}` runtime 展开 // - command + args: transport=stdio 用 // - auth_env_key: env 变量"名"(真 secret 不进 DB),admin 之后在 .env 配 // // 跟 marketplace 的 McpInstallModal 区别:那条是 user 装 template; // 这条是 admin 建 template 本身(`mcp_servers` 表)。 // // config_schema(user install 时的表单 schema)V1 不在本 modal 编辑 —— // admin 创建后去 editor 里加(future 加 sub-table UI 时再 inline)。 function McpAddModal({ onConfirm, onCancel }) { const [values, setValues] = React.useState({ server_id: "", name: "", description: "", transport: "http", http_url: "", command: "", args: "", auth_env_key: "", connect_timeout_seconds: "", operation_timeout_seconds: "", sse_read_timeout_seconds: "", sse_session_ttl_seconds: "", close_timeout_seconds: "", error_content_max_chars: "", exception_message_max_chars: "", }); // PR-S.18c · install-time form schema sub-editor。每行 {name, type, required, // description, default}。admin 这里加的字段就是 user marketplace install MCP // 时 modal 渲染的表单。 const [configFields, setConfigFields] = React.useState([]); const [headers, setHeaders] = React.useState([]); const [submitting, setSubmitting] = React.useState(false); const [err, setErr] = React.useState(null); function addHeader() { setHeaders((prev) => [...prev, { k: "", v: "" }]); } function updateHeader(idx, patch) { setHeaders((prev) => prev.map((h, i) => (i === idx ? { ...h, ...patch } : h))); } function removeHeader(idx) { setHeaders((prev) => prev.filter((_, i) => i !== idx)); } function addField() { setConfigFields((prev) => [...prev, { name: "", type: "secret", required: true, description: "", default: "", }]); } function updateField(idx, patch) { setConfigFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f))); } function removeField(idx) { setConfigFields((prev) => prev.filter((_, i) => i !== idx)); } function update(name, val) { setValues((prev) => ({ ...prev, [name]: val })); } function validate() { const sid = values.server_id.trim(); if (!sid) return "server_id is required"; if (!/^[a-z0-9][a-z0-9_-]*$/i.test(sid)) { return "server_id must be alphanumeric, '-' or '_' (no spaces / slashes)"; } if (!values.name.trim()) return "Display name is required"; if (values.transport === "http" || values.transport === "sse") { if (!values.http_url.trim()) return "Endpoint URL is required for http/sse"; } if (values.transport === "stdio") { if (!values.command.trim()) return "Command is required for stdio"; } return null; } function handleSubmit(e) { if (e) e.preventDefault(); if (submitting) return; const v = validate(); if (v) { setErr(v); return; } setSubmitting(true); setErr(null); // Build payload — only include fields relevant to chosen transport; // backend ignores irrelevant ones but cleaner not to send noise. const payload = { server_id: values.server_id.trim(), name: values.name.trim(), description: values.description.trim(), transport: values.transport, }; if (values.transport === "http" || values.transport === "sse") { payload.http_url = values.http_url.trim(); const headerPayload = dictFromKV(headers); if (Object.keys(headerPayload).length) payload.headers_template = headerPayload; } if (values.transport === "stdio") { payload.command = values.command.trim(); const argList = values.args .split(/\s+/).filter(Boolean); if (argList.length) payload.args = argList; } if (values.auth_env_key.trim()) { payload.auth_env_key = values.auth_env_key.trim(); } const runtimeOptions = runtimeOptionsFromValues(values); if (Object.keys(runtimeOptions).length) payload.runtime_options = runtimeOptions; // PR-S.18c · config_schema:剔空 name 的行,trim 字段;空表也允许(= // 无需 user 填任何 env / secret)。 const schemaPayload = configFields .map((f) => ({ name: (f.name || "").trim(), type: f.type || "string", required: !!f.required, description: (f.description || "").trim(), ...(f.default && String(f.default).trim() ? { default: String(f.default).trim() } : {}), })) .filter((f) => f.name); // 前端去重(同 name 会让 user install modal 渲染重复 input) const seen = new Set(); const dedup = []; for (const f of schemaPayload) { if (seen.has(f.name)) { setErr(`config_schema has duplicate field: ${f.name}`); setSubmitting(false); return; } seen.add(f.name); dedup.push(f); } if (dedup.length) payload.config_schema = dedup; onConfirm(payload).catch((e) => { // onConfirm 是 async;失败再开 modal,让 admin 修 setErr(typeof e === "string" ? e : (e?.message || "Create failed")); setSubmitting(false); }); } const isHttp = values.transport === "http" || values.transport === "sse"; const isStdio = values.transport === "stdio"; return (