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:
@@ -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()
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"type": "Type",
|
||||
"typeStdio": "stdio",
|
||||
"typeHttp": "http",
|
||||
"typeSse": "sse",
|
||||
"command": "Command",
|
||||
"commandPlaceholder": "npx or uvx",
|
||||
"args": "Arguments",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"type": "类型",
|
||||
"typeStdio": "stdio",
|
||||
"typeHttp": "http",
|
||||
"typeSse": "sse",
|
||||
"command": "命令",
|
||||
"commandPlaceholder": "npx 或 uvx",
|
||||
"args": "参数",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
// 通用字段
|
||||
|
||||
@@ -84,6 +84,7 @@ export const translateMcpBackendError = (
|
||||
}
|
||||
if (
|
||||
msg.includes("http 类型的 MCP 服务器缺少 url 字段") ||
|
||||
msg.includes("sse 类型的 MCP 服务器缺少 url 字段") ||
|
||||
msg.includes("必须包含 url 字段") ||
|
||||
msg === "URL 不能为空"
|
||||
) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user