From 561bdc0005ff90c26203aef4eebbd9525072789d Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 21 Sep 2026 10:07:57 +0800 Subject: [PATCH] fix(mcp): normalize source token case across core and visual editor The visual editor lowercases `type`, `transport`, and `source` before matching, but the core validator and parser still matched `source` case-sensitively. A hand-written `{"source": "Remote", "url": ...}` was therefore rendered happily by the form and then rejected on save with "Server 'x' has unsupported 'source' value: 'Remote'", while `parse_cursor_format` dropped the server with a warning. Route `source` through the same token normalizer as `type`/`transport`, and rename the shared helper to `normalized_token` since it now also covers the `local`/`remote` source vocabulary. Accepted spellings still normalize to the canonical persisted values. Also adds the regression coverage missing from the previous change: mixed-case `transport`/`source` validation, parsing, and canonical write assertions in mcp_contracts, plus camelCase `type` and mixed-case `source` fixtures in the visual editor round-trip table. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../src/mcp/config/cursor_format.rs | 8 +-- .../src/mcp/config/json_config.rs | 28 ++++---- .../tests/mcp_contracts.rs | 68 ++++++++++++++++++- .../config/components/mcpConfigForm.test.ts | 2 + 4 files changed, 88 insertions(+), 18 deletions(-) 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);