diff --git a/scripts/dev-api.js b/scripts/dev-api.js index ef1128a..f6ac033 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -4209,6 +4209,107 @@ export function mergeHermesQuickCommandsConfig(config = {}, form = {}) { return next } +function normalizeHermesMcpServerName(value) { + const name = String(value ?? '').trim() + if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) { + throw new Error(`mcp_servers.${name || ''} 服务名只能包含字母、数字、下划线、点和短横线`) + } + return name +} + +function normalizeHermesStringArray(value, key) { + if (value == null) return undefined + if (!Array.isArray(value)) throw new Error(`${key} 必须是字符串数组`) + return value.map((item, index) => { + if (typeof item !== 'string') throw new Error(`${key}.${index} 必须是字符串`) + return item + }) +} + +function normalizeHermesStringMap(value, key) { + if (value == null) return undefined + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${key} 必须是 JSON 对象`) + const normalized = {} + for (const [rawKey, rawValue] of Object.entries(value)) { + const itemKey = String(rawKey || '').trim() + if (!itemKey) throw new Error(`${key} 键名不能为空`) + if (typeof rawValue !== 'string') throw new Error(`${key}.${itemKey} 必须是字符串`) + normalized[itemKey] = rawValue + } + return normalized +} + +function normalizeHermesMcpTimeout(entry, field, key) { + if (!Object.hasOwn(entry, field) || entry[field] == null || entry[field] === '') { + delete entry[field] + return + } + entry[field] = parseHermesInteger(entry[field], key, 120, 1, 86400, true) +} + +function validateHermesMcpServers(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('mcp_servers 必须是 JSON 对象') + } + const normalized = {} + for (const [rawName, rawConfig] of Object.entries(value)) { + const name = normalizeHermesMcpServerName(rawName) + if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) { + throw new Error(`mcp_servers.${name} 必须是 JSON 对象`) + } + const entry = mergeConfigsPreservingFields(rawConfig, {}) + const command = typeof entry.command === 'string' ? entry.command.trim() : '' + const url = typeof entry.url === 'string' ? entry.url.trim() : '' + if (Object.hasOwn(entry, 'command')) { + if (!command) throw new Error(`mcp_servers.${name}.command 不能为空`) + entry.command = command + } + if (Object.hasOwn(entry, 'url')) { + if (!/^https?:\/\//i.test(url)) throw new Error(`mcp_servers.${name}.url 必须以 http:// 或 https:// 开头`) + entry.url = url + } + if (!command && !url) throw new Error(`mcp_servers.${name} 需要 command 或 url`) + if (Object.hasOwn(entry, 'args')) entry.args = normalizeHermesStringArray(entry.args, `mcp_servers.${name}.args`) + if (Object.hasOwn(entry, 'env')) entry.env = normalizeHermesStringMap(entry.env, `mcp_servers.${name}.env`) + if (Object.hasOwn(entry, 'headers')) entry.headers = normalizeHermesStringMap(entry.headers, `mcp_servers.${name}.headers`) + normalizeHermesMcpTimeout(entry, 'timeout', `mcp_servers.${name}.timeout`) + normalizeHermesMcpTimeout(entry, 'connect_timeout', `mcp_servers.${name}.connect_timeout`) + normalized[name] = entry + } + return normalized +} + +function parseHermesMcpServersJson(raw) { + const text = String(raw ?? '').trim() + if (!text) return {} + let value + try { + value = JSON.parse(text) + } catch (err) { + throw new Error(`mcp_servers JSON 格式错误: ${err.message}`) + } + return validateHermesMcpServers(value) +} + +export function buildHermesMcpServersConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const mcpServers = root.mcp_servers && typeof root.mcp_servers === 'object' && !Array.isArray(root.mcp_servers) + ? validateHermesMcpServers(root.mcp_servers) + : {} + return { + mcpServersJson: JSON.stringify(mcpServers, null, 2), + } +} + +export function mergeHermesMcpServersConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesMcpServersConfigValues(next) + const mcpServers = parseHermesMcpServersJson(Object.hasOwn(form, 'mcpServersJson') ? form.mcpServersJson : currentValues.mcpServersJson) + if (Object.keys(mcpServers).length) next.mcp_servers = mcpServers + else delete next.mcp_servers + return next +} + function isHermesProviderOverrideName(value) { return /^[a-zA-Z0-9_.-]+$/.test(String(value || '').trim()) } @@ -11267,6 +11368,27 @@ const handlers = { } }, + hermes_mcp_servers_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesMcpServersConfigValues(config), + } + }, + + hermes_mcp_servers_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesMcpServersConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesMcpServersConfigValues(next), + } + }, + hermes_agent_toolsets_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index ba1412b..198860f 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4407,6 +4407,201 @@ fn merge_hermes_quick_commands_config( Ok(()) } +fn is_hermes_mcp_server_name(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-')) +} + +fn normalize_hermes_json_string_array(value: &Value, key: &str) -> Result, String> { + let Some(items) = value.as_array() else { + return Err(format!("{key} 必须是字符串数组")); + }; + let mut normalized = Vec::with_capacity(items.len()); + for (index, item) in items.iter().enumerate() { + let Some(text) = item.as_str() else { + return Err(format!("{key}.{index} 必须是字符串")); + }; + normalized.push(Value::String(text.to_string())); + } + Ok(normalized) +} + +fn normalize_hermes_json_string_map( + value: &Value, + key: &str, +) -> Result, String> { + let Some(items) = value.as_object() else { + return Err(format!("{key} 必须是 JSON 对象")); + }; + let mut normalized = serde_json::Map::new(); + for (raw_key, raw_value) in items { + let item_key = raw_key.trim(); + if item_key.is_empty() { + return Err(format!("{key} 键名不能为空")); + } + let Some(text) = raw_value.as_str() else { + return Err(format!("{key}.{item_key} 必须是字符串")); + }; + normalized.insert(item_key.to_string(), Value::String(text.to_string())); + } + Ok(normalized) +} + +fn normalize_hermes_mcp_timeout( + entry: &mut serde_json::Map, + field: &str, + key: &str, +) -> Result<(), String> { + if !entry.contains_key(field) + || entry.get(field).is_some_and(|value| { + value.is_null() || value.as_str().is_some_and(|text| text.trim().is_empty()) + }) + { + entry.remove(field); + return Ok(()); + } + let value = entry.get(field).cloned().unwrap_or(Value::Null); + let parsed = if let Some(value) = value.as_i64() { + Some(value) + } else if let Some(value) = value.as_u64() { + i64::try_from(value).ok() + } else if let Some(value) = value.as_str() { + value.trim().parse::().ok() + } else { + None + }; + let parsed = parsed.ok_or_else(|| format!("{key} 必须是整数"))?; + let parsed = validate_hermes_i64(Some(parsed), key, 120, 1, 86400)?; + entry.insert(field.to_string(), Value::Number(parsed.into())); + Ok(()) +} + +fn validate_hermes_mcp_servers(value: &Value) -> Result, String> { + let Some(map) = value.as_object() else { + return Err("mcp_servers 必须是 JSON 对象".to_string()); + }; + let mut normalized = serde_json::Map::new(); + for (raw_name, raw_config) in map { + let name = raw_name.trim(); + if !is_hermes_mcp_server_name(name) { + return Err(format!( + "mcp_servers.{} 服务名只能包含字母、数字、下划线、点和短横线", + if name.is_empty() { "" } else { raw_name } + )); + } + let Some(config) = raw_config.as_object() else { + return Err(format!("mcp_servers.{name} 必须是 JSON 对象")); + }; + let mut entry = config.clone(); + let command = entry + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim() + .to_string(); + let url = entry + .get("url") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim() + .to_string(); + let command_is_empty = command.is_empty(); + let url_is_empty = url.is_empty(); + if entry.contains_key("command") { + if command_is_empty { + return Err(format!("mcp_servers.{name}.command 不能为空")); + } + entry.insert("command".to_string(), Value::String(command)); + } + if entry.contains_key("url") { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err(format!( + "mcp_servers.{name}.url 必须以 http:// 或 https:// 开头" + )); + } + entry.insert("url".to_string(), Value::String(url)); + } + if command_is_empty && url_is_empty { + return Err(format!("mcp_servers.{name} 需要 command 或 url")); + } + if let Some(args) = entry.get("args") { + let args = + normalize_hermes_json_string_array(args, &format!("mcp_servers.{name}.args"))?; + entry.insert("args".to_string(), Value::Array(args)); + } + if let Some(env) = entry.get("env") { + let env = normalize_hermes_json_string_map(env, &format!("mcp_servers.{name}.env"))?; + entry.insert("env".to_string(), Value::Object(env)); + } + if let Some(headers) = entry.get("headers") { + let headers = + normalize_hermes_json_string_map(headers, &format!("mcp_servers.{name}.headers"))?; + entry.insert("headers".to_string(), Value::Object(headers)); + } + normalize_hermes_mcp_timeout( + &mut entry, + "timeout", + &format!("mcp_servers.{name}.timeout"), + )?; + normalize_hermes_mcp_timeout( + &mut entry, + "connect_timeout", + &format!("mcp_servers.{name}.connect_timeout"), + )?; + normalized.insert(name.to_string(), Value::Object(entry)); + } + Ok(normalized) +} + +fn parse_hermes_mcp_servers_json( + raw: Option, +) -> Result, String> { + let text = raw.unwrap_or_default().trim().to_string(); + if text.is_empty() { + return Ok(serde_json::Map::new()); + } + let value: Value = + serde_json::from_str(&text).map_err(|err| format!("mcp_servers JSON 格式错误: {err}"))?; + validate_hermes_mcp_servers(&value) +} + +fn build_hermes_mcp_servers_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let mcp_servers = root + .and_then(|map| map.get(yaml_key("mcp_servers"))) + .and_then(|value| serde_json::to_value(value).ok()) + .and_then(|value| validate_hermes_mcp_servers(&value).ok()) + .unwrap_or_default(); + + serde_json::json!({ + "mcpServersJson": serde_json::to_string_pretty(&Value::Object(mcp_servers)).unwrap_or_else(|_| "{}".to_string()), + }) +} + +fn merge_hermes_mcp_servers_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_mcp_servers_config_values(config); + let mcp_servers = parse_hermes_mcp_servers_json( + form_string(form, "mcpServersJson") + .or_else(|| current["mcpServersJson"].as_str().map(ToString::to_string)), + )?; + + let root = ensure_yaml_object(config)?; + if mcp_servers.is_empty() { + root.remove(yaml_key("mcp_servers")); + } else { + let yaml_value = serde_yaml::to_value(Value::Object(mcp_servers)) + .map_err(|err| format!("mcp_servers 转换 YAML 失败: {err}"))?; + root.insert(yaml_key("mcp_servers"), yaml_value); + } + Ok(()) +} + fn is_hermes_provider_override_name(value: &str) -> bool { let value = value.trim(); !value.is_empty() @@ -8096,6 +8291,30 @@ pub fn hermes_provider_overrides_config_save(form: Value) -> Result Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_mcp_servers_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_mcp_servers_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_mcp_servers_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_mcp_servers_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_agent_toolsets_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -15814,6 +16033,185 @@ streaming: } } +#[cfg(test)] +mod hermes_mcp_servers_config_tests { + use super::{build_hermes_mcp_servers_config_values, merge_hermes_mcp_servers_config}; + use serde_json::json; + + #[test] + fn mcp_servers_values_have_empty_defaults() { + let config = serde_yaml::Value::Mapping(Default::default()); + let values = build_hermes_mcp_servers_config_values(&config); + + assert_eq!(values["mcpServersJson"], "{}"); + } + + #[test] + fn mcp_servers_values_read_yaml_mapping() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +mcp_servers: + time: + command: uvx + args: + - mcp-server-time + notion: + url: https://mcp.notion.com/mcp + connect_timeout: 30 +"#, + ) + .unwrap(); + + let values = build_hermes_mcp_servers_config_values(&config); + let mapping: serde_json::Value = + serde_json::from_str(values["mcpServersJson"].as_str().unwrap()).unwrap(); + + assert_eq!(mapping["time"]["command"], "uvx"); + assert_eq!(mapping["time"]["args"][0], "mcp-server-time"); + assert_eq!(mapping["notion"]["url"], "https://mcp.notion.com/mcp"); + assert_eq!(mapping["notion"]["connect_timeout"], 30); + } + + #[test] + fn merge_mcp_servers_config_preserves_unknown_fields_and_unrelated_yaml() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: openrouter +mcp_servers: + time: + command: uvx + args: + - old-server + sampling: + enabled: true + model: gemini-3-flash +memory: + memory_enabled: true +"#, + ) + .unwrap(); + + merge_hermes_mcp_servers_config( + &mut config, + &json!({ + "mcpServersJson": serde_json::to_string(&json!({ + "time": { + "command": "uvx", + "args": ["mcp-server-time"], + "timeout": 120, + "sampling": { + "enabled": true, + "model": "gemini-3-flash" + } + }, + "notion": { + "url": "https://mcp.notion.com/mcp", + "headers": { + "Authorization": "Bearer token" + }, + "connect_timeout": 30 + } + })).unwrap(), + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("openrouter")); + assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(true)); + assert_eq!( + config["mcp_servers"]["time"]["command"].as_str(), + Some("uvx") + ); + assert_eq!( + config["mcp_servers"]["time"]["args"][0].as_str(), + Some("mcp-server-time") + ); + assert_eq!(config["mcp_servers"]["time"]["timeout"].as_i64(), Some(120)); + assert_eq!( + config["mcp_servers"]["time"]["sampling"]["model"].as_str(), + Some("gemini-3-flash") + ); + assert_eq!( + config["mcp_servers"]["notion"]["headers"]["Authorization"].as_str(), + Some("Bearer token") + ); + assert_eq!( + config["mcp_servers"]["notion"]["connect_timeout"].as_i64(), + Some(30) + ); + } + + #[test] + fn merge_mcp_servers_config_removes_empty_mapping() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +mcp_servers: + time: + command: uvx +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_mcp_servers_config(&mut config, &json!({ "mcpServersJson": "{}" })).unwrap(); + + assert!(config["mcp_servers"].is_null()); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + } + + #[test] + fn merge_mcp_servers_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(Default::default()); + let err = merge_hermes_mcp_servers_config(&mut config, &json!({ "mcpServersJson": "[" })) + .unwrap_err(); + assert!(err.contains("mcp_servers JSON")); + + let err = merge_hermes_mcp_servers_config( + &mut config, + &json!({ "mcpServersJson": serde_json::to_string(&json!({ "bad server": { "command": "uvx" } })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("mcp_servers.bad server")); + + let err = merge_hermes_mcp_servers_config( + &mut config, + &json!({ "mcpServersJson": serde_json::to_string(&json!({ "time": "uvx" })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("mcp_servers.time")); + + let err = merge_hermes_mcp_servers_config( + &mut config, + &json!({ "mcpServersJson": serde_json::to_string(&json!({ "time": { "command": "" } })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("mcp_servers.time.command")); + + let err = merge_hermes_mcp_servers_config( + &mut config, + &json!({ "mcpServersJson": serde_json::to_string(&json!({ "notion": { "url": "ftp://example.com/mcp" } })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("mcp_servers.notion.url")); + + let err = merge_hermes_mcp_servers_config( + &mut config, + &json!({ "mcpServersJson": serde_json::to_string(&json!({ "time": { "command": "uvx", "args": "mcp-server-time" } })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("mcp_servers.time.args")); + + let err = merge_hermes_mcp_servers_config( + &mut config, + &json!({ "mcpServersJson": serde_json::to_string(&json!({ "time": { "command": "uvx", "timeout": 0 } })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("mcp_servers.time.timeout")); + } +} + #[cfg(test)] mod hermes_provider_overrides_config_tests { use super::{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 31b2bc6..96dc8d4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -279,6 +279,8 @@ pub fn run() { hermes::hermes_quick_commands_config_save, hermes::hermes_provider_overrides_config_read, hermes::hermes_provider_overrides_config_save, + hermes::hermes_mcp_servers_config_read, + hermes::hermes_mcp_servers_config_save, hermes::hermes_agent_toolsets_config_read, hermes::hermes_agent_toolsets_config_save, hermes::hermes_platform_toolsets_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 0a1756f..16032d3 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -87,6 +87,10 @@ const PROVIDER_OVERRIDES_DEFAULTS = { providerOverridesJson: '{}', } +const MCP_SERVERS_DEFAULTS = { + mcpServersJson: '{}', +} + const AGENT_TOOLSETS_DEFAULTS = { disabledToolsets: '', } @@ -278,6 +282,7 @@ export function render() { let skillsValues = { ...SKILLS_DEFAULTS } let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } let providerOverridesValues = { ...PROVIDER_OVERRIDES_DEFAULTS } + let mcpServersValues = { ...MCP_SERVERS_DEFAULTS } let agentToolsetsValues = { ...AGENT_TOOLSETS_DEFAULTS } let platformToolsetsValues = { ...PLATFORM_TOOLSETS_DEFAULTS } let agentRuntimeValues = { ...AGENT_RUNTIME_DEFAULTS } @@ -308,6 +313,7 @@ export function render() { let skillsLoading = true let quickCommandsLoading = true let providerOverridesLoading = true + let mcpServersLoading = true let agentToolsetsLoading = true let platformToolsetsLoading = true let agentRuntimeLoading = true @@ -338,6 +344,7 @@ export function render() { let skillsSaving = false let quickCommandsSaving = false let providerOverridesSaving = false + let mcpServersSaving = false let agentToolsetsSaving = false let platformToolsetsSaving = false let agentRuntimeSaving = false @@ -368,6 +375,7 @@ export function render() { let skillsError = null let quickCommandsError = null let providerOverridesError = null + let mcpServersError = null let agentToolsetsError = null let platformToolsetsError = null let agentRuntimeError = null @@ -396,7 +404,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || providerOverridesLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving } function option(labelKey, value, selected) { @@ -858,7 +866,7 @@ export function render() { } function renderQuickCommandsConfigPanel() { - const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -884,7 +892,7 @@ export function render() { } function renderProviderOverridesConfigPanel() { - const disabled = loading || saving || providerOverridesLoading || providerOverridesSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || providerOverridesLoading || providerOverridesSaving || quickCommandsSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -909,8 +917,34 @@ export function render() { ` } + function renderMcpServersConfigPanel() { + const disabled = loading || saving || mcpServersLoading || mcpServersSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesMcpServersConfigTitle')}
+
${t('engine.hermesMcpServersConfigDesc')}
+
+
+ ${mcpServersSaving ? t('engine.hermesConfigStatusSaving') : mcpServersLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesMcpServersConfigStatusReady')} + +
+
+
+ ${renderError(mcpServersError)} + +
${t('engine.hermesMcpServersConfigFootnote')}
+
+
+ ` + } + function renderAgentToolsetsConfigPanel() { - const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || mcpServersSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -936,7 +970,7 @@ export function render() { } function renderPlatformToolsetsConfigPanel() { - const disabled = loading || saving || platformToolsetsLoading || platformToolsetsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || platformToolsetsLoading || platformToolsetsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || mcpServersSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -1822,6 +1856,7 @@ export function render() { ${renderSkillsConfigPanel()} ${renderQuickCommandsConfigPanel()} ${renderProviderOverridesConfigPanel()} + ${renderMcpServersConfigPanel()} ${renderAgentToolsetsConfigPanel()} ${renderPlatformToolsetsConfigPanel()} ${renderAgentRuntimeConfigPanel()} @@ -1859,6 +1894,7 @@ export function render() { el.querySelector('#hm-skills-config-save')?.addEventListener('click', saveSkillsConfig) el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig) el.querySelector('#hm-provider-overrides-save')?.addEventListener('click', saveProviderOverridesConfig) + el.querySelector('#hm-mcp-servers-save')?.addEventListener('click', saveMcpServersConfig) el.querySelector('#hm-agent-toolsets-save')?.addEventListener('click', saveAgentToolsetsConfig) el.querySelector('#hm-platform-toolsets-save')?.addEventListener('click', savePlatformToolsetsConfig) el.querySelector('#hm-agent-runtime-save')?.addEventListener('click', saveAgentRuntimeConfig) @@ -1939,6 +1975,11 @@ export function render() { providerOverridesValues = { ...PROVIDER_OVERRIDES_DEFAULTS, ...(data?.values || {}) } } + async function loadMcpServersConfig() { + const data = await api.hermesMcpServersConfigRead() + mcpServersValues = { ...MCP_SERVERS_DEFAULTS, ...(data?.values || {}) } + } + async function loadAgentToolsetsConfig() { const data = await api.hermesAgentToolsetsConfigRead() agentToolsetsValues = { ...AGENT_TOOLSETS_DEFAULTS, ...(data?.values || {}) } @@ -2042,6 +2083,7 @@ export function render() { skillsLoading = true quickCommandsLoading = true providerOverridesLoading = true + mcpServersLoading = true agentToolsetsLoading = true platformToolsetsLoading = true agentRuntimeLoading = true @@ -2072,6 +2114,7 @@ export function render() { skillsError = null quickCommandsError = null providerOverridesError = null + mcpServersError = null agentToolsetsError = null platformToolsetsError = null agentRuntimeError = null @@ -2274,6 +2317,14 @@ export function render() { providerOverridesLoading = false draw() } + try { + await loadMcpServersConfig() + } catch (err) { + mcpServersError = humanizeError(err, t('engine.hermesMcpServersConfigLoadFailed') || 'Load MCP servers config failed') + } finally { + mcpServersLoading = false + draw() + } try { await loadAgentToolsetsConfig() } catch (err) { @@ -2384,6 +2435,9 @@ export function render() { try { await loadProviderOverridesConfig() } catch {} + try { + await loadMcpServersConfig() + } catch {} try { await loadAgentToolsetsConfig() } catch {} @@ -2756,6 +2810,31 @@ export function render() { } } + async function saveMcpServersConfig() { + const form = { + mcpServersJson: el.querySelector('#hm-mcp-servers-json')?.value || '{}', + } + mcpServersSaving = true + mcpServersError = null + draw() + try { + const result = await api.hermesMcpServersConfigSave(form) + mcpServersValues = { ...MCP_SERVERS_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesMcpServersConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + mcpServersError = humanizeError(err, t('engine.hermesMcpServersConfigSaveFailed') || 'Save MCP servers config failed') + toast(mcpServersError, 'error') + } finally { + mcpServersSaving = false + draw() + } + } + async function saveAgentToolsetsConfig() { const form = { disabledToolsets: el.querySelector('#hm-agent-disabled-toolsets')?.value || '', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index fa63114..d95354c 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -531,6 +531,8 @@ export const api = { hermesQuickCommandsConfigSave: (form) => invoke('hermes_quick_commands_config_save', { form }), hermesProviderOverridesConfigRead: () => invoke('hermes_provider_overrides_config_read'), hermesProviderOverridesConfigSave: (form) => invoke('hermes_provider_overrides_config_save', { form }), + hermesMcpServersConfigRead: () => invoke('hermes_mcp_servers_config_read'), + hermesMcpServersConfigSave: (form) => invoke('hermes_mcp_servers_config_save', { form }), hermesAgentToolsetsConfigRead: () => invoke('hermes_agent_toolsets_config_read'), hermesAgentToolsetsConfigSave: (form) => invoke('hermes_agent_toolsets_config_save', { form }), hermesPlatformToolsetsConfigRead: () => invoke('hermes_platform_toolsets_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index b6436bf..70b3ac4 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -832,6 +832,15 @@ export default { hermesProviderOverridesConfigSaveFailed: _('保存 Provider 超时覆盖失败', 'Save provider timeout overrides failed', '儲存 Provider 逾時覆蓋失敗'), hermesProviderOverridesConfigJson: _('providers JSON 映射', 'providers JSON map', 'providers JSON 映射'), hermesProviderOverridesConfigFootnote: _('键名是 provider slug。支持 request_timeout_seconds、stale_timeout_seconds,以及 models..timeout_seconds / stale_timeout_seconds;未知字段会保留在 raw YAML 中。', 'Keys are provider slugs. Supports request_timeout_seconds, stale_timeout_seconds, and models..timeout_seconds / stale_timeout_seconds. Unknown fields stay in raw YAML.', '鍵名是 provider slug。支援 request_timeout_seconds、stale_timeout_seconds,以及 models..timeout_seconds / stale_timeout_seconds;未知欄位會保留在 raw YAML 中。'), + hermesMcpServersConfigTitle: _('MCP 服务', 'MCP servers', 'MCP 服務'), + hermesMcpServersConfigDesc: _('配置 Hermes 可连接的 MCP 服务,用于接入外部工具。支持 stdio command/args 和 HTTP url/headers,适合团队工具扩展。', 'Configure MCP servers Hermes can connect to for external tools. Supports stdio command/args and HTTP url/headers for team tool integrations.', '設定 Hermes 可連接的 MCP 服務,用於接入外部工具。支援 stdio command/args 和 HTTP url/headers,適合團隊工具擴充。'), + hermesMcpServersConfigStatusReady: _('结构化 JSON', 'structured JSON', '結構化 JSON'), + hermesMcpServersConfigSave: _('保存 MCP 服务', 'Save MCP servers', '儲存 MCP 服務'), + hermesMcpServersConfigSaveSuccess: _('MCP 服务配置已保存,建议重启 Hermes Gateway 生效', 'MCP server settings saved. Restart Hermes Gateway to take effect.', 'MCP 服務設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesMcpServersConfigLoadFailed: _('加载 MCP 服务配置失败', 'Load MCP server settings failed', '載入 MCP 服務設定失敗'), + hermesMcpServersConfigSaveFailed: _('保存 MCP 服务配置失败', 'Save MCP server settings failed', '儲存 MCP 服務設定失敗'), + hermesMcpServersConfigJson: _('mcp_servers JSON 映射', 'mcp_servers JSON map', 'mcp_servers JSON 映射'), + hermesMcpServersConfigFootnote: _('键名是 MCP 服务名。stdio 服务使用 command/args/env,HTTP 服务使用 url/headers;timeout 和 connect_timeout 单位为秒。未知字段会保留在 raw YAML 中。', 'Keys are MCP server names. Stdio servers use command/args/env, HTTP servers use url/headers; timeout and connect_timeout are in seconds. Unknown fields stay in raw YAML.', '鍵名是 MCP 服務名。stdio 服務使用 command/args/env,HTTP 服務使用 url/headers;timeout 和 connect_timeout 單位為秒。未知欄位會保留在 raw YAML 中。'), hermesAgentToolsetsConfigTitle: _('全局工具开关', 'Global tool switches', '全域工具開關'), hermesAgentToolsetsConfigDesc: _('在 CLI 和所有 Gateway 渠道里统一禁用指定工具集,适合公网部署、只读模式或临时收紧高风险能力。', 'Disable selected toolsets globally across CLI and all Gateway channels. Useful for public deployments, read-only mode, or temporarily reducing high-risk capabilities.', '在 CLI 和所有 Gateway 渠道裡統一停用指定工具集,適合公開部署、唯讀模式或暫時收緊高風險能力。'), hermesAgentToolsetsConfigStatusReady: _('结构化列表', 'structured list', '結構化清單'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 2903d3a..71a4c1a 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -67,6 +67,15 @@ test('Hermes 配置页会暴露 provider 超时覆盖结构化配置字段', () } }) +test('Hermes 配置页会暴露 MCP 服务结构化配置字段', () => { + for (const id of [ + 'hm-mcp-servers-save', + 'hm-mcp-servers-json', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露全局禁用工具集结构化配置字段', () => { for (const id of [ 'hm-agent-toolsets-save', @@ -361,6 +370,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('SkillsConfig') || key.includes('QuickCommandsConfig') || key.includes('ProviderOverridesConfig') || + key.includes('McpServersConfig') || key.includes('AgentToolsetsConfig') || key.includes('AgentRuntimeConfig') || key.includes('UnauthorizedDmConfig') || diff --git a/tests/hermes-mcp-servers-config.test.js b/tests/hermes-mcp-servers-config.test.js new file mode 100644 index 0000000..2fe826e --- /dev/null +++ b/tests/hermes-mcp-servers-config.test.js @@ -0,0 +1,132 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesMcpServersConfigValues, + mergeHermesMcpServersConfig, +} from '../scripts/dev-api.js' + +test('Hermes MCP 服务配置读取会提供空对象默认值', () => { + const values = buildHermesMcpServersConfigValues({}) + + assert.deepEqual(values, { + mcpServersJson: '{}', + }) +}) + +test('Hermes MCP 服务配置读取会格式化 stdio 和 HTTP 服务', () => { + const values = buildHermesMcpServersConfigValues({ + mcp_servers: { + time: { + command: 'uvx', + args: ['mcp-server-time'], + }, + notion: { + url: 'https://mcp.notion.com/mcp', + connect_timeout: 30, + }, + }, + }) + const mapping = JSON.parse(values.mcpServersJson) + + assert.deepEqual(mapping.time, { + command: 'uvx', + args: ['mcp-server-time'], + }) + assert.deepEqual(mapping.notion, { + url: 'https://mcp.notion.com/mcp', + connect_timeout: 30, + }) +}) + +test('Hermes MCP 服务配置保存会保留未知字段并写入 mcp_servers', () => { + const next = mergeHermesMcpServersConfig({ + model: { provider: 'openrouter' }, + mcp_servers: { + time: { + command: 'uvx', + args: ['old-server'], + sampling: { + enabled: true, + model: 'gemini-3-flash', + }, + }, + }, + memory: { memory_enabled: true }, + }, { + mcpServersJson: JSON.stringify({ + time: { + command: 'uvx', + args: ['mcp-server-time'], + timeout: 120, + sampling: { + enabled: true, + model: 'gemini-3-flash', + }, + }, + notion: { + url: 'https://mcp.notion.com/mcp', + headers: { + Authorization: 'Bearer token', + }, + connect_timeout: 30, + }, + }), + }) + + assert.deepEqual(next.model, { provider: 'openrouter' }) + assert.deepEqual(next.memory, { memory_enabled: true }) + assert.equal(next.mcp_servers.time.command, 'uvx') + assert.deepEqual(next.mcp_servers.time.args, ['mcp-server-time']) + assert.equal(next.mcp_servers.time.timeout, 120) + assert.equal(next.mcp_servers.time.sampling.enabled, true) + assert.equal(next.mcp_servers.time.sampling.model, 'gemini-3-flash') + assert.equal(next.mcp_servers.notion.url, 'https://mcp.notion.com/mcp') + assert.equal(next.mcp_servers.notion.headers.Authorization, 'Bearer token') + assert.equal(next.mcp_servers.notion.connect_timeout, 30) +}) + +test('Hermes MCP 服务配置保存空对象会移除 mcp_servers', () => { + const next = mergeHermesMcpServersConfig({ + mcp_servers: { + time: { command: 'uvx' }, + }, + streaming: { enabled: true }, + }, { + mcpServersJson: '{}', + }) + + assert.equal(next.mcp_servers, undefined) + assert.deepEqual(next.streaming, { enabled: true }) +}) + +test('Hermes MCP 服务配置保存会拒绝非法 JSON、名称、结构和超时', () => { + assert.throws( + () => mergeHermesMcpServersConfig({}, { mcpServersJson: '[' }), + /mcp_servers JSON/, + ) + assert.throws( + () => mergeHermesMcpServersConfig({}, { mcpServersJson: JSON.stringify({ 'bad server': { command: 'uvx' } }) }), + /mcp_servers\.bad server/, + ) + assert.throws( + () => mergeHermesMcpServersConfig({}, { mcpServersJson: JSON.stringify({ time: 'uvx' }) }), + /mcp_servers\.time/, + ) + assert.throws( + () => mergeHermesMcpServersConfig({}, { mcpServersJson: JSON.stringify({ time: { command: '' } }) }), + /mcp_servers\.time\.command/, + ) + assert.throws( + () => mergeHermesMcpServersConfig({}, { mcpServersJson: JSON.stringify({ notion: { url: 'ftp://example.com/mcp' } }) }), + /mcp_servers\.notion\.url/, + ) + assert.throws( + () => mergeHermesMcpServersConfig({}, { mcpServersJson: JSON.stringify({ time: { command: 'uvx', args: 'mcp-server-time' } }) }), + /mcp_servers\.time\.args/, + ) + assert.throws( + () => mergeHermesMcpServersConfig({}, { mcpServersJson: JSON.stringify({ time: { command: 'uvx', timeout: 0 } }) }), + /mcp_servers\.time\.timeout/, + ) +})