fix(mcp): update MCP wizard to support http type and improve args input

- Replace deprecated 'sse' type with 'http' (as per Claude Code official docs)
- Add HTTP-specific fields: url (required) and headers (optional)
- Implement dynamic UI: show different fields based on selected type
- Improve args input: support multi-line input (one argument per line)
- Add headers parsing supporting both 'KEY: VALUE' and 'KEY=VALUE' formats
- Update backend validation to enforce type-specific required fields
- Update i18n translations (zh/en) with new HTTP-related labels
This commit is contained in:
Jason
2025-10-09 12:04:37 +08:00
parent d0fe9d7533
commit 9471cb0d19
5 changed files with 216 additions and 91 deletions

View File

@@ -80,12 +80,24 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, String> {
.get("type") .get("type")
.and_then(|x| x.as_str()) .and_then(|x| x.as_str())
.unwrap_or(""); .unwrap_or("");
if t != "stdio" && t != "sse" { if t != "stdio" && t != "http" {
return Err("MCP 服务器 type 必须是 'stdio' 或 'sse'".into()); return Err("MCP 服务器 type 必须是 'stdio' 或 'http'".into());
} }
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.is_empty() { // stdio 类型必须有 command
return Err("MCP 服务器缺少 command".into()); if t == "stdio" {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.is_empty() {
return Err("stdio 类型的 MCP 服务器缺少 command 字段".into());
}
}
// http 类型必须有 url
if t == "http" {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.is_empty() {
return Err("http 类型的 MCP 服务器缺少 url 字段".into());
}
} }
let path = user_config_path(); let path = user_config_path();

View File

@@ -30,6 +30,34 @@ const parseEnvText = (text: string): Record<string, string> => {
return env; return env;
}; };
/**
* 解析headers文本为对象支持 KEY: VALUE 或 KEY=VALUE
*/
const parseHeadersText = (text: string): Record<string, string> => {
const lines = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const headers: Record<string, string> = {};
for (const l of lines) {
// 支持 KEY: VALUE 或 KEY=VALUE
const colonIdx = l.indexOf(":");
const equalIdx = l.indexOf("=");
let idx = -1;
if (colonIdx > 0 && (equalIdx === -1 || colonIdx < equalIdx)) {
idx = colonIdx;
} else if (equalIdx > 0) {
idx = equalIdx;
}
if (idx > 0) {
const k = l.slice(0, idx).trim();
const v = l.slice(idx + 1).trim();
if (k) headers[k] = v;
}
}
return headers;
};
/** /**
* MCP 配置向导模态框 * MCP 配置向导模态框
* 帮助用户快速生成 MCP JSON 配置 * 帮助用户快速生成 MCP JSON 配置
@@ -40,35 +68,54 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onApply, onApply,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [wizardType, setWizardType] = useState<"stdio" | "sse">("stdio"); const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio");
// stdio 字段
const [wizardCommand, setWizardCommand] = useState(""); const [wizardCommand, setWizardCommand] = useState("");
const [wizardArgs, setWizardArgs] = useState(""); const [wizardArgs, setWizardArgs] = useState("");
const [wizardCwd, setWizardCwd] = useState(""); const [wizardCwd, setWizardCwd] = useState("");
const [wizardEnv, setWizardEnv] = useState(""); const [wizardEnv, setWizardEnv] = useState("");
// http 字段
const [wizardUrl, setWizardUrl] = useState("");
const [wizardHeaders, setWizardHeaders] = useState("");
// 生成预览 JSON // 生成预览 JSON
const generatePreview = (): string => { const generatePreview = (): string => {
const config: McpServer = { const config: McpServer = {
type: wizardType, type: wizardType,
command: wizardCommand.trim(),
}; };
// 添加可选字段 if (wizardType === "stdio") {
if (wizardArgs.trim()) { // stdio 类型必需字段
config.args = wizardArgs config.command = wizardCommand.trim();
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
if (wizardCwd.trim()) { // 可选字段
config.cwd = wizardCwd.trim(); if (wizardArgs.trim()) {
} config.args = wizardArgs
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
if (wizardEnv.trim()) { if (wizardCwd.trim()) {
const env = parseEnvText(wizardEnv); config.cwd = wizardCwd.trim();
if (Object.keys(env).length > 0) { }
config.env = env;
if (wizardEnv.trim()) {
const env = parseEnvText(wizardEnv);
if (Object.keys(env).length > 0) {
config.env = env;
}
}
} else {
// http 类型必需字段
config.url = wizardUrl.trim();
// 可选字段
if (wizardHeaders.trim()) {
const headers = parseHeadersText(wizardHeaders);
if (Object.keys(headers).length > 0) {
config.headers = headers;
}
} }
} }
@@ -76,10 +123,14 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
}; };
const handleApply = () => { const handleApply = () => {
if (!wizardCommand.trim()) { if (wizardType === "stdio" && !wizardCommand.trim()) {
alert(t("mcp.error.commandRequired")); alert(t("mcp.error.commandRequired"));
return; return;
} }
if (wizardType === "http" && !wizardUrl.trim()) {
alert(t("mcp.wizard.urlRequired"));
return;
}
const json = generatePreview(); const json = generatePreview();
onApply(json); onApply(json);
@@ -93,6 +144,8 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
setWizardArgs(""); setWizardArgs("");
setWizardCwd(""); setWizardCwd("");
setWizardEnv(""); setWizardEnv("");
setWizardUrl("");
setWizardHeaders("");
onClose(); onClose();
}; };
@@ -163,7 +216,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
value="stdio" value="stdio"
checked={wizardType === "stdio"} checked={wizardType === "stdio"}
onChange={(e) => onChange={(e) =>
setWizardType(e.target.value as "stdio" | "sse") setWizardType(e.target.value as "stdio" | "http")
} }
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2" className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/> />
@@ -174,84 +227,129 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<label className="inline-flex items-center gap-2 cursor-pointer"> <label className="inline-flex items-center gap-2 cursor-pointer">
<input <input
type="radio" type="radio"
value="sse" value="http"
checked={wizardType === "sse"} checked={wizardType === "http"}
onChange={(e) => onChange={(e) =>
setWizardType(e.target.value as "stdio" | "sse") setWizardType(e.target.value as "stdio" | "http")
} }
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2" className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/> />
<span className="text-sm text-gray-900 dark:text-gray-100"> <span className="text-sm text-gray-900 dark:text-gray-100">
sse http
</span> </span>
</label> </label>
</div> </div>
</div> </div>
{/* Command */} {/* Stdio 类型字段 */}
<div> {wizardType === "stdio" && (
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100"> <>
{t("mcp.wizard.command")}{" "} {/* Command */}
<span className="text-red-500">*</span> <div>
</label> <label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<input {t("mcp.wizard.command")}{" "}
type="text" <span className="text-red-500">*</span>
value={wizardCommand} </label>
onChange={(e) => setWizardCommand(e.target.value)} <input
onKeyDown={handleKeyDown} type="text"
placeholder={t("mcp.wizard.commandPlaceholder")} value={wizardCommand}
required onChange={(e) => setWizardCommand(e.target.value)}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100" onKeyDown={handleKeyDown}
/> placeholder={t("mcp.wizard.commandPlaceholder")}
</div> required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{/* Args */} {/* Args */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100"> <label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.args")} {t("mcp.wizard.args")}
</label> </label>
<input <textarea
type="text" value={wizardArgs}
value={wizardArgs} onChange={(e) => setWizardArgs(e.target.value)}
onChange={(e) => setWizardArgs(e.target.value)} placeholder={t("mcp.wizard.argsPlaceholder")}
onKeyDown={handleKeyDown} rows={3}
placeholder={t("mcp.wizard.argsPlaceholder")} className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100" />
/> </div>
</div>
{/* CWD */} {/* CWD */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100"> <label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.cwd")} {t("mcp.wizard.cwd")}
</label> </label>
<input <input
type="text" type="text"
value={wizardCwd} value={wizardCwd}
onChange={(e) => setWizardCwd(e.target.value)} onChange={(e) => setWizardCwd(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.cwdPlaceholder")} placeholder={t("mcp.wizard.cwdPlaceholder")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100" className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/> />
</div> </div>
{/* Env */} {/* Env */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100"> <label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.env")} {t("mcp.wizard.env")}
</label> </label>
<textarea <textarea
value={wizardEnv} value={wizardEnv}
onChange={(e) => setWizardEnv(e.target.value)} onChange={(e) => setWizardEnv(e.target.value)}
placeholder={t("mcp.wizard.envPlaceholder")} placeholder={t("mcp.wizard.envPlaceholder")}
rows={3} rows={3}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y" className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
/> />
</div> </div>
</>
)}
{/* HTTP 类型字段 */}
{wizardType === "http" && (
<>
{/* URL */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.url")}{" "}
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={wizardUrl}
onChange={(e) => setWizardUrl(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.urlPlaceholder")}
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{/* Headers */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.headers")}
</label>
<textarea
value={wizardHeaders}
onChange={(e) => setWizardHeaders(e.target.value)}
placeholder={t("mcp.wizard.headersPlaceholder")}
rows={3}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
/>
</div>
</>
)}
</div> </div>
{/* Preview */} {/* Preview */}
{(wizardCommand || wizardArgs || wizardCwd || wizardEnv) && ( {(wizardCommand ||
wizardArgs ||
wizardCwd ||
wizardEnv ||
wizardUrl ||
wizardHeaders) && (
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700"> <div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"> <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.preview")} {t("mcp.wizard.preview")}

View File

@@ -280,12 +280,17 @@
"type": "Type", "type": "Type",
"command": "Command", "command": "Command",
"commandPlaceholder": "uvx", "commandPlaceholder": "uvx",
"args": "Arguments", "args": "Arguments (one per line)",
"argsPlaceholder": "mcp-server-fetch", "argsPlaceholder": "mcp-server-fetch\n--config config.json",
"cwd": "Working Directory", "cwd": "Working Directory",
"cwdPlaceholder": "/path/to/project", "cwdPlaceholder": "/path/to/project",
"env": "Environment Variables", "env": "Environment Variables",
"envPlaceholder": "KEY=VALUE\n(one per line)", "envPlaceholder": "KEY=VALUE\n(one per line)",
"url": "URL",
"urlPlaceholder": "https://api.example.com/mcp",
"urlRequired": "Please enter URL",
"headers": "Headers (optional)",
"headersPlaceholder": "Authorization: Bearer token\nContent-Type: application/json",
"preview": "Configuration Preview", "preview": "Configuration Preview",
"apply": "Apply Configuration" "apply": "Apply Configuration"
}, },

View File

@@ -280,12 +280,17 @@
"type": "类型", "type": "类型",
"command": "命令", "command": "命令",
"commandPlaceholder": "uvx", "commandPlaceholder": "uvx",
"args": "参数", "args": "参数(每行一个)",
"argsPlaceholder": "mcp-server-fetch", "argsPlaceholder": "mcp-server-fetch\n--config config.json",
"cwd": "工作目录", "cwd": "工作目录",
"cwdPlaceholder": "/path/to/project", "cwdPlaceholder": "/path/to/project",
"env": "环境变量", "env": "环境变量",
"envPlaceholder": "KEY=VALUE\n(一行一个)", "envPlaceholder": "KEY=VALUE\n(一行一个)",
"url": "URL",
"urlPlaceholder": "https://api.example.com/mcp",
"urlRequired": "请输入 URL",
"headers": "请求头(可选)",
"headersPlaceholder": "Authorization: Bearer token\nContent-Type: application/json",
"preview": "配置预览", "preview": "配置预览",
"apply": "应用配置" "apply": "应用配置"
}, },

View File

@@ -55,11 +55,16 @@ export interface Settings {
// MCP 服务器定义(宽松:允许扩展字段) // MCP 服务器定义(宽松:允许扩展字段)
export interface McpServer { export interface McpServer {
type: "stdio" | "sse"; type: "stdio" | "http";
command: string; // stdio 字段
command?: string;
args?: string[]; args?: string[];
env?: Record<string, string>; env?: Record<string, string>;
cwd?: string; cwd?: string;
// http 字段
url?: string;
headers?: Record<string, string>;
// 通用字段
enabled?: boolean; // 是否启用该 MCP 服务器,默认 true enabled?: boolean; // 是否启用该 MCP 服务器,默认 true
[key: string]: any; [key: string]: any;
} }