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 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<bool, AppError> {
}
}
// 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()
},
));
}
}

View File

@@ -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<usize, AppError>
}
}
}
"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<toml_edit::Table, AppError>
}
}
}
"http" => {
"http" | "sse" => {
let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or("");
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 });
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;
}

View File

@@ -80,13 +80,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
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<McpWizardModalProps> = ({
}
}
} else {
// http 类型必需字段
// http 和 sse 类型必需字段
config.url = wizardUrl.trim();
// 可选字段
@@ -139,7 +139,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
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<McpWizardModalProps> = ({
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<McpWizardModalProps> = ({
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<McpWizardModalProps> = ({
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<McpWizardModalProps> = ({
{t("mcp.wizard.typeHttp")}
</span>
</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>
@@ -339,8 +353,8 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
</>
)}
{/* HTTP 类型字段 */}
{wizardType === "http" && (
{/* HTTP 和 SSE 类型字段 */}
{(wizardType === "http" || wizardType === "sse") && (
<>
{/* URL */}
<div>

View File

@@ -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");
}
}

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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"],
});
}

View File

@@ -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<string, string>;
cwd?: string;
// http 字段
// http 和 sse 字段
url?: string;
headers?: Record<string, string>;
// 通用字段

View File

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

View File

@@ -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");

View File

@@ -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", () => {
});
});
});