From bfc27349b3b349a613351923500fc0c93d8fb165 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Nov 2025 16:15:17 +0800 Subject: [PATCH] feat(mcp): add SSE (Server-Sent Events) transport type support Add comprehensive support for SSE transport type to MCP server configuration, enabling real-time streaming connections alongside existing stdio and http types. Backend Changes: - Add SSE type validation in mcp.rs validate_server_spec() - Extend Codex TOML import/export to handle SSE servers - Update claude_mcp.rs legacy API for backward compatibility - Unify http/sse handling in json_server_to_toml_table() Frontend Changes: - Extend McpServerSpec type definition to include "sse" - Add SSE radio button to configuration wizard UI - Update wizard form logic to handle SSE url and headers - Add SSE validation in McpFormModal submission Validation & Error Handling: - Add SSE support in useMcpValidation hook (TOML/JSON) - Extend tomlUtils normalizeServerConfig for SSE parsing - Update Zod schemas (common.ts, mcp.ts) with SSE enum - Add SSE error message mapping in errorUtils Internationalization: - Add "typeSse" translations (zh: "sse", en: "sse") Tests: - Add SSE validation test cases in useMcpValidation.test.tsx SSE Configuration Format: { "type": "sse", "url": "https://api.example.com/sse", "headers": { "Authorization": "Bearer token" } } --- src-tauri/src/claude_mcp.rs | 15 ++++++++---- src-tauri/src/mcp.rs | 23 ++++++++++++------ src/components/mcp/McpFormModal.tsx | 5 +++- src/components/mcp/McpWizardModal.tsx | 32 ++++++++++++++++++-------- src/components/mcp/useMcpValidation.ts | 10 ++++++-- src/i18n/locales/en.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/schemas/common.ts | 9 +++++--- src/lib/schemas/mcp.ts | 6 ++--- src/types.ts | 4 ++-- src/utils/errorUtils.ts | 1 + src/utils/tomlUtils.ts | 6 ++--- tests/hooks/useMcpValidation.test.tsx | 15 +++++++++++- 13 files changed, 92 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index 00245bd..8a8bb35 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -118,9 +118,10 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { let t_opt = spec.get("type").and_then(|x| x.as_str()); let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理) let is_http = t_opt.map(|t| t == "http").unwrap_or(false); - if !(is_stdio || is_http) { + let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false); + if !(is_stdio || is_http || is_sse) { return Err(AppError::McpValidation( - "MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into(), + "MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(), )); } @@ -134,12 +135,16 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { } } - // http 类型必须有 url - if is_http { + // http/sse 类型必须有 url + if is_http || is_sse { let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); if url.is_empty() { return Err(AppError::McpValidation( - "http 类型的 MCP 服务器缺少 url 字段".into(), + if is_http { + "http 类型的 MCP 服务器缺少 url 字段".into() + } else { + "sse 类型的 MCP 服务器缺少 url 字段".into() + }, )); } } diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 32c9fec..360bbec 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use crate::app_config::{AppType, McpConfig, MultiAppConfig}; use crate::error::AppError; -/// 基础校验:允许 stdio/http;或省略 type(视为 stdio)。对应必填字段存在 +/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在 fn validate_server_spec(spec: &Value) -> Result<(), AppError> { if !spec.is_object() { return Err(AppError::McpValidation( @@ -12,13 +12,14 @@ fn validate_server_spec(spec: &Value) -> Result<(), AppError> { )); } let t_opt = spec.get("type").and_then(|x| x.as_str()); - // 支持两种:stdio/http;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致) + // 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致) let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); let is_http = t_opt.map(|t| t == "http").unwrap_or(false); + let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false); - if !(is_stdio || is_http) { + if !(is_stdio || is_http || is_sse) { return Err(AppError::McpValidation( - "MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into(), + "MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(), )); } @@ -38,6 +39,14 @@ fn validate_server_spec(spec: &Value) -> Result<(), AppError> { )); } } + if is_sse { + let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); + if url.trim().is_empty() { + return Err(AppError::McpValidation( + "sse 类型的 MCP 服务器缺少 url 字段".into(), + )); + } + } Ok(()) } @@ -469,7 +478,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result } } } - "http" => { + "http" | "sse" => { if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) { spec.insert("url".into(), json!(url)); } @@ -643,7 +652,7 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> { } } } - "http" => { + "http" | "sse" => { let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or(""); t["url"] = toml_edit::value(url); if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) { @@ -858,7 +867,7 @@ fn json_server_to_toml_table(spec: &Value) -> Result } } } - "http" => { + "http" | "sse" => { let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or(""); t["url"] = toml_edit::value(url); diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index fbaf12f..3361737 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -346,7 +346,10 @@ const McpFormModal: React.FC = ({ toast.error(t("mcp.error.commandRequired"), { duration: 3000 }); return; } - if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) { + if ( + (serverSpec?.type === "http" || serverSpec?.type === "sse") && + !serverSpec?.url?.trim() + ) { toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 }); return; } diff --git a/src/components/mcp/McpWizardModal.tsx b/src/components/mcp/McpWizardModal.tsx index 70b1325..9a663e4 100644 --- a/src/components/mcp/McpWizardModal.tsx +++ b/src/components/mcp/McpWizardModal.tsx @@ -80,13 +80,13 @@ const McpWizardModal: React.FC = ({ initialServer, }) => { const { t } = useTranslation(); - const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio"); + const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">("stdio"); const [wizardTitle, setWizardTitle] = useState(""); // stdio 字段 const [wizardCommand, setWizardCommand] = useState(""); const [wizardArgs, setWizardArgs] = useState(""); const [wizardEnv, setWizardEnv] = useState(""); - // http 字段 + // http 和 sse 字段 const [wizardUrl, setWizardUrl] = useState(""); const [wizardHeaders, setWizardHeaders] = useState(""); @@ -115,7 +115,7 @@ const McpWizardModal: React.FC = ({ } } } else { - // http 类型必需字段 + // http 和 sse 类型必需字段 config.url = wizardUrl.trim(); // 可选字段 @@ -139,7 +139,7 @@ const McpWizardModal: React.FC = ({ toast.error(t("mcp.error.commandRequired"), { duration: 3000 }); return; } - if (wizardType === "http" && !wizardUrl.trim()) { + if ((wizardType === "http" || wizardType === "sse") && !wizardUrl.trim()) { toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 }); return; } @@ -179,7 +179,7 @@ const McpWizardModal: React.FC = ({ setWizardType(resolvedType); - if (resolvedType === "http") { + if (resolvedType === "http" || resolvedType === "sse") { setWizardUrl(initialServer?.url ?? ""); const headersCandidate = initialServer?.headers; const headers = @@ -250,7 +250,7 @@ const McpWizardModal: React.FC = ({ value="stdio" checked={wizardType === "stdio"} onChange={(e) => - setWizardType(e.target.value as "stdio" | "http") + setWizardType(e.target.value as "stdio" | "http" | "sse") } className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2" /> @@ -264,7 +264,7 @@ const McpWizardModal: React.FC = ({ value="http" checked={wizardType === "http"} onChange={(e) => - setWizardType(e.target.value as "stdio" | "http") + setWizardType(e.target.value as "stdio" | "http" | "sse") } className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2" /> @@ -272,6 +272,20 @@ const McpWizardModal: React.FC = ({ {t("mcp.wizard.typeHttp")} + @@ -339,8 +353,8 @@ const McpWizardModal: React.FC = ({ )} - {/* HTTP 类型字段 */} - {wizardType === "http" && ( + {/* HTTP 和 SSE 类型字段 */} + {(wizardType === "http" || wizardType === "sse") && ( <> {/* URL */}
diff --git a/src/components/mcp/useMcpValidation.ts b/src/components/mcp/useMcpValidation.ts index cdf83f8..53169dd 100644 --- a/src/components/mcp/useMcpValidation.ts +++ b/src/components/mcp/useMcpValidation.ts @@ -41,7 +41,10 @@ export function useMcpValidation() { if (server.type === "stdio" && !server.command?.trim()) { return t("mcp.error.commandRequired"); } - if (server.type === "http" && !server.url?.trim()) { + if ( + (server.type === "http" || server.type === "sse") && + !server.url?.trim() + ) { return t("mcp.wizard.urlRequired"); } } catch (e: any) { @@ -73,7 +76,10 @@ export function useMcpValidation() { if (typ === "stdio" && !(obj as any)?.command?.trim()) { return t("mcp.error.commandRequired"); } - if (typ === "http" && !(obj as any)?.url?.trim()) { + if ( + (typ === "http" || typ === "sse") && + !(obj as any)?.url?.trim() + ) { return t("mcp.wizard.urlRequired"); } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ef12d2d..8fa2f60 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -483,6 +483,7 @@ "type": "Type", "typeStdio": "stdio", "typeHttp": "http", + "typeSse": "sse", "command": "Command", "commandPlaceholder": "npx or uvx", "args": "Arguments", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 699e0c7..24b397d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -483,6 +483,7 @@ "type": "类型", "typeStdio": "stdio", "typeHttp": "http", + "typeSse": "sse", "command": "命令", "commandPlaceholder": "npx 或 uvx", "args": "参数", diff --git a/src/lib/schemas/common.ts b/src/lib/schemas/common.ts index 08d5cd5..a042dab 100644 --- a/src/lib/schemas/common.ts +++ b/src/lib/schemas/common.ts @@ -58,7 +58,7 @@ export const jsonConfigSchema = z * 通用的 TOML 配置文本校验: * - 允许为空(由上层业务决定是否必填) * - 语法与结构有效 - * - 针对 stdio/http 的必填字段(command/url)进行提示 + * - 针对 stdio/http/sse 的必填字段(command/url)进行提示 */ export const tomlConfigSchema = z.string().superRefine((value, ctx) => { const err = validateToml(value); @@ -80,10 +80,13 @@ export const tomlConfigSchema = z.string().superRefine((value, ctx) => { message: "stdio 类型需填写 command", }); } - if (server.type === "http" && !server.url?.trim()) { + if ( + (server.type === "http" || server.type === "sse") && + !server.url?.trim() + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "http 类型需填写 url", + message: `${server.type} 类型需填写 url`, }); } } catch (e: any) { diff --git a/src/lib/schemas/mcp.ts b/src/lib/schemas/mcp.ts index 2159609..65aeab0 100644 --- a/src/lib/schemas/mcp.ts +++ b/src/lib/schemas/mcp.ts @@ -2,7 +2,7 @@ import { z } from "zod"; const mcpServerSpecSchema = z .object({ - type: z.enum(["stdio", "http"]).optional(), + type: z.enum(["stdio", "http", "sse"]).optional(), command: z.string().trim().optional(), args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), @@ -19,10 +19,10 @@ const mcpServerSpecSchema = z path: ["command"], }); } - if (type === "http" && !server.url?.trim()) { + if ((type === "http" || type === "sse") && !server.url?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "http 类型需填写 url", + message: `${type} 类型需填写 url`, path: ["url"], }); } diff --git a/src/types.ts b/src/types.ts index 9fb64c8..40fc5d1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,13 +112,13 @@ export interface Settings { // MCP 服务器连接参数(宽松:允许扩展字段) export interface McpServerSpec { // 可选:社区常见 .mcp.json 中 stdio 配置可不写 type - type?: "stdio" | "http"; + type?: "stdio" | "http" | "sse"; // stdio 字段 command?: string; args?: string[]; env?: Record; cwd?: string; - // http 字段 + // http 和 sse 字段 url?: string; headers?: Record; // 通用字段 diff --git a/src/utils/errorUtils.ts b/src/utils/errorUtils.ts index df81457..e903014 100644 --- a/src/utils/errorUtils.ts +++ b/src/utils/errorUtils.ts @@ -84,6 +84,7 @@ export const translateMcpBackendError = ( } if ( msg.includes("http 类型的 MCP 服务器缺少 url 字段") || + msg.includes("sse 类型的 MCP 服务器缺少 url 字段") || msg.includes("必须包含 url 字段") || msg === "URL 不能为空" ) { diff --git a/src/utils/tomlUtils.ts b/src/utils/tomlUtils.ts index 90e87b5..baa507b 100644 --- a/src/utils/tomlUtils.ts +++ b/src/utils/tomlUtils.ts @@ -145,13 +145,13 @@ function normalizeServerConfig(config: any): McpServerSpec { } return server; - } else if (type === "http") { + } else if (type === "http" || type === "sse") { if (!config.url || typeof config.url !== "string") { - throw new Error("http 类型的 MCP 服务器必须包含 url 字段"); + throw new Error(`${type} 类型的 MCP 服务器必须包含 url 字段`); } const server: McpServerSpec = { - type: "http", + type: type as "http" | "sse", url: config.url, }; knownFields.add("type"); diff --git a/tests/hooks/useMcpValidation.test.tsx b/tests/hooks/useMcpValidation.test.tsx index 8a624d5..e3e033a 100644 --- a/tests/hooks/useMcpValidation.test.tsx +++ b/tests/hooks/useMcpValidation.test.tsx @@ -87,6 +87,15 @@ describe("useMcpValidation", () => { expect(validateTomlConfig("foo")).toBe("mcp.wizard.urlRequired"); }); + it("returns url required when sse server missing url", () => { + tomlToMcpServerMock.mockReturnValue({ + type: "sse", + url: "", + }); + const { validateTomlConfig } = getHookResult(); + expect(validateTomlConfig("foo")).toBe("mcp.wizard.urlRequired"); + }); + it("surface tomlToMcpServer errors via formatter", () => { tomlToMcpServerMock.mockImplementation(() => { throw new Error("normalize failed"); @@ -128,6 +137,11 @@ describe("useMcpValidation", () => { expect(validateJsonConfig('{"type":"http","url":""}')).toBe("mcp.wizard.urlRequired"); }); + it("requires url for sse type", () => { + const { validateJsonConfig } = getHookResult(); + expect(validateJsonConfig('{"type":"sse","url":""}')).toBe("mcp.wizard.urlRequired"); + }); + it("returns empty string when json config valid", () => { const { validateJsonConfig } = getHookResult(); expect( @@ -142,4 +156,3 @@ describe("useMcpValidation", () => { }); }); }); -