diff --git a/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs b/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs index 42adadcb7e..47742a29a1 100644 --- a/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs +++ b/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs @@ -4,11 +4,11 @@ use log::warn; use crate::mcp::server::{MCPServerConfig, MCPServerTransport, MCPServerType}; -use super::json_config::normalized_transport_token; +use super::json_config::normalized_token; use super::ConfigLocation; fn parse_source(value: &str) -> Option { - match value.trim() { + match normalized_token(value).as_str() { "local" => Some(MCPServerType::Local), "remote" => Some(MCPServerType::Remote), _ => None, @@ -16,7 +16,7 @@ fn parse_source(value: &str) -> Option { } fn parse_transport(value: &str) -> Option { - match normalized_transport_token(value).as_str() { + match normalized_token(value).as_str() { "stdio" => Some(MCPServerTransport::Stdio), "sse" => Some(MCPServerTransport::Sse), "http" | "streamable_http" | "streamable-http" | "streamablehttp" => { @@ -27,7 +27,7 @@ fn parse_transport(value: &str) -> Option { } fn parse_legacy_type(value: &str) -> Option<(Option, Option)> { - match normalized_transport_token(value).as_str() { + match normalized_token(value).as_str() { "stdio" => Some((None, Some(MCPServerTransport::Stdio))), "local" => Some((Some(MCPServerType::Local), Some(MCPServerTransport::Stdio))), "sse" => Some((Some(MCPServerType::Remote), Some(MCPServerTransport::Sse))), diff --git a/src/crates/services/services-integrations/src/mcp/config/json_config.rs b/src/crates/services/services-integrations/src/mcp/config/json_config.rs index 753c1d39f2..4f12dee9b8 100644 --- a/src/crates/services/services-integrations/src/mcp/config/json_config.rs +++ b/src/crates/services/services-integrations/src/mcp/config/json_config.rs @@ -23,26 +23,28 @@ impl fmt::Display for MCPJsonConfigValidationError { impl std::error::Error for MCPJsonConfigValidationError {} +/// Canonicalizes a `type` / `transport` / `source` token before matching. +/// +/// The MCP client ecosystem is inconsistent about casing: Cursor, Cline, and +/// other clients emit `streamableHttp`, while others use `streamable-http`, +/// `streamable_http`, `streamablehttp`, or `http`. The visual editor already +/// lowercases these tokens before matching, so the core validator and parser +/// must do the same, or a config the form accepts fails again when it is saved. +/// Accepting a spelling never changes the canonical value we persist. +pub(super) fn normalized_token(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + fn normalize_source(value: &str) -> Option<&'static str> { - match value.trim() { + match normalized_token(value).as_str() { "local" => Some("local"), "remote" => Some("remote"), _ => None, } } -/// Canonicalizes a transport/`type` token before matching. -/// -/// The MCP client ecosystem is inconsistent about casing: Cursor, Cline, and -/// other clients emit `streamableHttp`, while others use `streamable-http`, -/// `streamable_http`, `streamablehttp`, or `http`. Lowercasing before matching -/// accepts every spelling without changing the canonical value we persist. -pub(super) fn normalized_transport_token(value: &str) -> String { - value.trim().to_ascii_lowercase() -} - fn normalize_transport(value: &str) -> Option<&'static str> { - match normalized_transport_token(value).as_str() { + match normalized_token(value).as_str() { "stdio" => Some("stdio"), "sse" => Some("sse"), "http" | "streamable_http" | "streamable-http" | "streamablehttp" => { @@ -53,7 +55,7 @@ fn normalize_transport(value: &str) -> Option<&'static str> { } fn normalize_legacy_type(value: &str) -> Option<(Option<&'static str>, Option<&'static str>)> { - match normalized_transport_token(value).as_str() { + match normalized_token(value).as_str() { "stdio" => Some((None, Some("stdio"))), "local" => Some((Some("local"), Some("stdio"))), "sse" => Some((Some("remote"), Some("sse"))), diff --git a/src/crates/services/services-integrations/tests/mcp_contracts.rs b/src/crates/services/services-integrations/tests/mcp_contracts.rs index 2ddf1e72c4..d22f29536f 100644 --- a/src/crates/services/services-integrations/tests/mcp_contracts.rs +++ b/src/crates/services/services-integrations/tests/mcp_contracts.rs @@ -2251,7 +2251,12 @@ fn mcp_config_accepts_camel_case_streamable_http_type() { Some(MCPServerTransport::StreamableHttp) ); - for alias in ["streamable-http", "streamable_http", "streamablehttp", "HTTP"] { + for alias in [ + "streamable-http", + "streamable_http", + "streamablehttp", + "HTTP", + ] { validate_mcp_json_config(&serde_json::json!({ "mcpServers": { "alias": { "type": alias, "url": "https://example.com/mcp" } @@ -2260,3 +2265,64 @@ fn mcp_config_accepts_camel_case_streamable_http_type() { .unwrap_or_else(|error| panic!("type '{}' must validate: {}", alias, error)); } } + +#[test] +fn mcp_config_normalizes_token_case_for_type_transport_and_source() { + // The visual editor lowercases `type`, `transport`, and `source` before + // matching. The core validator and parser must agree, otherwise a config + // the form renders happily fails again when the document is saved. + let cases = [ + ( + serde_json::json!({ "type": "StreamableHTTP", "url": "https://example.com/mcp" }), + "streamable-http", + MCPServerTransport::StreamableHttp, + ), + ( + serde_json::json!({ + "transport": "STREAMABLE-HTTP", + "url": "https://example.com/mcp" + }), + "streamable-http", + MCPServerTransport::StreamableHttp, + ), + ( + serde_json::json!({ + "source": "REMOTE", + "transport": "SSE", + "url": "https://example.com/sse" + }), + "sse", + MCPServerTransport::Sse, + ), + ( + serde_json::json!({ "source": "Local", "command": "npx", "args": ["-y", "server"] }), + "stdio", + MCPServerTransport::Stdio, + ), + ]; + + for (server, canonical_type, transport) in cases { + let config = serde_json::json!({ "mcpServers": { "case": server.clone() } }); + + validate_mcp_json_config(&config) + .unwrap_or_else(|error| panic!("'{}' must validate: {}", server, error)); + + let parsed = parse_cursor_format(&config); + assert_eq!( + parsed.len(), + 1, + "'{}' must be parsed instead of silently dropped", + server + ); + assert_eq!(parsed[0].transport, Some(transport), "for '{}'", server); + + // Accepting a spelling must not change the canonical token we persist. + let written = config_to_cursor_format(&parsed[0]); + assert_eq!( + written["type"].as_str(), + Some(canonical_type), + "'{}' must persist the canonical token", + server + ); + } +} diff --git a/src/web-ui/src/infrastructure/config/components/mcpConfigForm.test.ts b/src/web-ui/src/infrastructure/config/components/mcpConfigForm.test.ts index 6705dde9a5..75114a8676 100644 --- a/src/web-ui/src/infrastructure/config/components/mcpConfigForm.test.ts +++ b/src/web-ui/src/infrastructure/config/components/mcpConfigForm.test.ts @@ -19,6 +19,8 @@ describe('MCP visual configuration compatibility', () => { { source: 'remote', transport: 'streamable_http', url: 'https://example.test/mcp', timeouts: { startupMs: 12001, catalogMs: 7000, future: 23 } }, { type: ' remote ', transport: ' http ', source: ' remote ', url: 'https://example.test/mcp' }, { type: 'local', source: 'remote', transport: 'http', url: 'https://example.test/mcp' }, + { type: 'streamableHttp', url: 'https://example.test/mcp' }, + { source: 'REMOTE', transport: 'SSE', url: 'https://example.test/sse' }, ])('round-trips existing data without adding defaults or dropping fields: %j', entry => { const document = documentWith(entry); const original = JSON.stringify(document);