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" }
}
This commit is contained in:
Jason
2025-11-16 16:15:17 +08:00
parent 4fc7413ffa
commit bfc27349b3
13 changed files with 92 additions and 36 deletions

View File

@@ -118,9 +118,10 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
let t_opt = spec.get("type").and_then(|x| x.as_str()); 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_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
let is_http = t_opt.map(|t| t == "http").unwrap_or(false); 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( 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<bool, AppError> {
} }
} }
// http 类型必须有 url // http/sse 类型必须有 url
if is_http { if is_http || is_sse {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.is_empty() { if url.is_empty() {
return Err(AppError::McpValidation( return Err(AppError::McpValidation(
"http 类型的 MCP 服务器缺少 url 字段".into(), if is_http {
"http 类型的 MCP 服务器缺少 url 字段".into()
} else {
"sse 类型的 MCP 服务器缺少 url 字段".into()
},
)); ));
} }
} }

View File

@@ -4,7 +4,7 @@ use std::collections::HashMap;
use crate::app_config::{AppType, McpConfig, MultiAppConfig}; use crate::app_config::{AppType, McpConfig, MultiAppConfig};
use crate::error::AppError; use crate::error::AppError;
/// 基础校验:允许 stdio/http或省略 type视为 stdio。对应必填字段存在 /// 基础校验:允许 stdio/http/sse;或省略 type视为 stdio。对应必填字段存在
fn validate_server_spec(spec: &Value) -> Result<(), AppError> { fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
if !spec.is_object() { if !spec.is_object() {
return Err(AppError::McpValidation( 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()); 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_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
let is_http = t_opt.map(|t| t == "http").unwrap_or(false); 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( 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(()) Ok(())
} }
@@ -469,7 +478,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
} }
} }
} }
"http" => { "http" | "sse" => {
if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) { if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) {
spec.insert("url".into(), json!(url)); 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(""); let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or("");
t["url"] = toml_edit::value(url); t["url"] = toml_edit::value(url);
if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) { 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<toml_edit::Table, AppError>
} }
} }
} }
"http" => { "http" | "sse" => {
let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or(""); let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or("");
t["url"] = toml_edit::value(url); t["url"] = toml_edit::value(url);

View File

@@ -346,7 +346,10 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
toast.error(t("mcp.error.commandRequired"), { duration: 3000 }); toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return; 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 }); toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
return; return;
} }

View File

@@ -80,13 +80,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
initialServer, initialServer,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio"); const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">("stdio");
const [wizardTitle, setWizardTitle] = useState(""); const [wizardTitle, setWizardTitle] = useState("");
// stdio 字段 // stdio 字段
const [wizardCommand, setWizardCommand] = useState(""); const [wizardCommand, setWizardCommand] = useState("");
const [wizardArgs, setWizardArgs] = useState(""); const [wizardArgs, setWizardArgs] = useState("");
const [wizardEnv, setWizardEnv] = useState(""); const [wizardEnv, setWizardEnv] = useState("");
// http 字段 // http 和 sse 字段
const [wizardUrl, setWizardUrl] = useState(""); const [wizardUrl, setWizardUrl] = useState("");
const [wizardHeaders, setWizardHeaders] = useState(""); const [wizardHeaders, setWizardHeaders] = useState("");
@@ -115,7 +115,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
} }
} }
} else { } else {
// http 类型必需字段 // http 和 sse 类型必需字段
config.url = wizardUrl.trim(); config.url = wizardUrl.trim();
// 可选字段 // 可选字段
@@ -139,7 +139,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
toast.error(t("mcp.error.commandRequired"), { duration: 3000 }); toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return; return;
} }
if (wizardType === "http" && !wizardUrl.trim()) { if ((wizardType === "http" || wizardType === "sse") && !wizardUrl.trim()) {
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 }); toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
return; return;
} }
@@ -179,7 +179,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
setWizardType(resolvedType); setWizardType(resolvedType);
if (resolvedType === "http") { if (resolvedType === "http" || resolvedType === "sse") {
setWizardUrl(initialServer?.url ?? ""); setWizardUrl(initialServer?.url ?? "");
const headersCandidate = initialServer?.headers; const headersCandidate = initialServer?.headers;
const headers = const headers =
@@ -250,7 +250,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" | "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" 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<McpWizardModalProps> = ({
value="http" value="http"
checked={wizardType === "http"} checked={wizardType === "http"}
onChange={(e) => 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" 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<McpWizardModalProps> = ({
{t("mcp.wizard.typeHttp")} {t("mcp.wizard.typeHttp")}
</span> </span>
</label> </label>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="sse"
checked={wizardType === "sse"}
onChange={(e) =>
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"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeSse")}
</span>
</label>
</div> </div>
</div> </div>
@@ -339,8 +353,8 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
</> </>
)} )}
{/* HTTP 类型字段 */} {/* HTTP 和 SSE 类型字段 */}
{wizardType === "http" && ( {(wizardType === "http" || wizardType === "sse") && (
<> <>
{/* URL */} {/* URL */}
<div> <div>

View File

@@ -41,7 +41,10 @@ export function useMcpValidation() {
if (server.type === "stdio" && !server.command?.trim()) { if (server.type === "stdio" && !server.command?.trim()) {
return t("mcp.error.commandRequired"); 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"); return t("mcp.wizard.urlRequired");
} }
} catch (e: any) { } catch (e: any) {
@@ -73,7 +76,10 @@ export function useMcpValidation() {
if (typ === "stdio" && !(obj as any)?.command?.trim()) { if (typ === "stdio" && !(obj as any)?.command?.trim()) {
return t("mcp.error.commandRequired"); 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"); return t("mcp.wizard.urlRequired");
} }
} }

View File

@@ -483,6 +483,7 @@
"type": "Type", "type": "Type",
"typeStdio": "stdio", "typeStdio": "stdio",
"typeHttp": "http", "typeHttp": "http",
"typeSse": "sse",
"command": "Command", "command": "Command",
"commandPlaceholder": "npx or uvx", "commandPlaceholder": "npx or uvx",
"args": "Arguments", "args": "Arguments",

View File

@@ -483,6 +483,7 @@
"type": "类型", "type": "类型",
"typeStdio": "stdio", "typeStdio": "stdio",
"typeHttp": "http", "typeHttp": "http",
"typeSse": "sse",
"command": "命令", "command": "命令",
"commandPlaceholder": "npx 或 uvx", "commandPlaceholder": "npx 或 uvx",
"args": "参数", "args": "参数",

View File

@@ -58,7 +58,7 @@ export const jsonConfigSchema = z
* 通用的 TOML 配置文本校验: * 通用的 TOML 配置文本校验:
* - 允许为空(由上层业务决定是否必填) * - 允许为空(由上层业务决定是否必填)
* - 语法与结构有效 * - 语法与结构有效
* - 针对 stdio/http 的必填字段command/url进行提示 * - 针对 stdio/http/sse 的必填字段command/url进行提示
*/ */
export const tomlConfigSchema = z.string().superRefine((value, ctx) => { export const tomlConfigSchema = z.string().superRefine((value, ctx) => {
const err = validateToml(value); const err = validateToml(value);
@@ -80,10 +80,13 @@ export const tomlConfigSchema = z.string().superRefine((value, ctx) => {
message: "stdio 类型需填写 command", message: "stdio 类型需填写 command",
}); });
} }
if (server.type === "http" && !server.url?.trim()) { if (
(server.type === "http" || server.type === "sse") &&
!server.url?.trim()
) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: "http 类型需填写 url", message: `${server.type} 类型需填写 url`,
}); });
} }
} catch (e: any) { } catch (e: any) {

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
const mcpServerSpecSchema = z const mcpServerSpecSchema = z
.object({ .object({
type: z.enum(["stdio", "http"]).optional(), type: z.enum(["stdio", "http", "sse"]).optional(),
command: z.string().trim().optional(), command: z.string().trim().optional(),
args: z.array(z.string()).optional(), args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(), env: z.record(z.string(), z.string()).optional(),
@@ -19,10 +19,10 @@ const mcpServerSpecSchema = z
path: ["command"], path: ["command"],
}); });
} }
if (type === "http" && !server.url?.trim()) { if ((type === "http" || type === "sse") && !server.url?.trim()) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: "http 类型需填写 url", message: `${type} 类型需填写 url`,
path: ["url"], path: ["url"],
}); });
} }

View File

@@ -112,13 +112,13 @@ export interface Settings {
// MCP 服务器连接参数(宽松:允许扩展字段) // MCP 服务器连接参数(宽松:允许扩展字段)
export interface McpServerSpec { export interface McpServerSpec {
// 可选:社区常见 .mcp.json 中 stdio 配置可不写 type // 可选:社区常见 .mcp.json 中 stdio 配置可不写 type
type?: "stdio" | "http"; type?: "stdio" | "http" | "sse";
// stdio 字段 // stdio 字段
command?: string; command?: string;
args?: string[]; args?: string[];
env?: Record<string, string>; env?: Record<string, string>;
cwd?: string; cwd?: string;
// http 字段 // http 和 sse 字段
url?: string; url?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
// 通用字段 // 通用字段

View File

@@ -84,6 +84,7 @@ export const translateMcpBackendError = (
} }
if ( if (
msg.includes("http 类型的 MCP 服务器缺少 url 字段") || msg.includes("http 类型的 MCP 服务器缺少 url 字段") ||
msg.includes("sse 类型的 MCP 服务器缺少 url 字段") ||
msg.includes("必须包含 url 字段") || msg.includes("必须包含 url 字段") ||
msg === "URL 不能为空" msg === "URL 不能为空"
) { ) {

View File

@@ -145,13 +145,13 @@ function normalizeServerConfig(config: any): McpServerSpec {
} }
return server; return server;
} else if (type === "http") { } else if (type === "http" || type === "sse") {
if (!config.url || typeof config.url !== "string") { if (!config.url || typeof config.url !== "string") {
throw new Error("http 类型的 MCP 服务器必须包含 url 字段"); throw new Error(`${type} 类型的 MCP 服务器必须包含 url 字段`);
} }
const server: McpServerSpec = { const server: McpServerSpec = {
type: "http", type: type as "http" | "sse",
url: config.url, url: config.url,
}; };
knownFields.add("type"); knownFields.add("type");

View File

@@ -87,6 +87,15 @@ describe("useMcpValidation", () => {
expect(validateTomlConfig("foo")).toBe("mcp.wizard.urlRequired"); 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", () => { it("surface tomlToMcpServer errors via formatter", () => {
tomlToMcpServerMock.mockImplementation(() => { tomlToMcpServerMock.mockImplementation(() => {
throw new Error("normalize failed"); throw new Error("normalize failed");
@@ -128,6 +137,11 @@ describe("useMcpValidation", () => {
expect(validateJsonConfig('{"type":"http","url":""}')).toBe("mcp.wizard.urlRequired"); 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", () => { it("returns empty string when json config valid", () => {
const { validateJsonConfig } = getHookResult(); const { validateJsonConfig } = getHookResult();
expect( expect(
@@ -142,4 +156,3 @@ describe("useMcpValidation", () => {
}); });
}); });
}); });