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:
@@ -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();
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": "应用配置"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user