Skip to content

Commit 561bdc0

Browse files
wgqqqqqbitfun-ai
andcommitted
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>
1 parent 294240e commit 561bdc0

4 files changed

Lines changed: 88 additions & 18 deletions

File tree

‎src/crates/services/services-integrations/src/mcp/config/cursor_format.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,19 @@ use log::warn;
44

55
use crate::mcp::server::{MCPServerConfig, MCPServerTransport, MCPServerType};
66

7-
use super::json_config::normalized_transport_token;
7+
use super::json_config::normalized_token;
88
use super::ConfigLocation;
99

1010
fn parse_source(value: &str) -> Option<MCPServerType> {
11-
match value.trim() {
11+
match normalized_token(value).as_str() {
1212
"local" => Some(MCPServerType::Local),
1313
"remote" => Some(MCPServerType::Remote),
1414
_ => None,
1515
}
1616
}
1717

1818
fn parse_transport(value: &str) -> Option<MCPServerTransport> {
19-
match normalized_transport_token(value).as_str() {
19+
match normalized_token(value).as_str() {
2020
"stdio" => Some(MCPServerTransport::Stdio),
2121
"sse" => Some(MCPServerTransport::Sse),
2222
"http" | "streamable_http" | "streamable-http" | "streamablehttp" => {
@@ -27,7 +27,7 @@ fn parse_transport(value: &str) -> Option<MCPServerTransport> {
2727
}
2828

2929
fn parse_legacy_type(value: &str) -> Option<(Option<MCPServerType>, Option<MCPServerTransport>)> {
30-
match normalized_transport_token(value).as_str() {
30+
match normalized_token(value).as_str() {
3131
"stdio" => Some((None, Some(MCPServerTransport::Stdio))),
3232
"local" => Some((Some(MCPServerType::Local), Some(MCPServerTransport::Stdio))),
3333
"sse" => Some((Some(MCPServerType::Remote), Some(MCPServerTransport::Sse))),

‎src/crates/services/services-integrations/src/mcp/config/json_config.rs‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,28 @@ impl fmt::Display for MCPJsonConfigValidationError {
2323

2424
impl std::error::Error for MCPJsonConfigValidationError {}
2525

26+
/// Canonicalizes a `type` / `transport` / `source` token before matching.
27+
///
28+
/// The MCP client ecosystem is inconsistent about casing: Cursor, Cline, and
29+
/// other clients emit `streamableHttp`, while others use `streamable-http`,
30+
/// `streamable_http`, `streamablehttp`, or `http`. The visual editor already
31+
/// lowercases these tokens before matching, so the core validator and parser
32+
/// must do the same, or a config the form accepts fails again when it is saved.
33+
/// Accepting a spelling never changes the canonical value we persist.
34+
pub(super) fn normalized_token(value: &str) -> String {
35+
value.trim().to_ascii_lowercase()
36+
}
37+
2638
fn normalize_source(value: &str) -> Option<&'static str> {
27-
match value.trim() {
39+
match normalized_token(value).as_str() {
2840
"local" => Some("local"),
2941
"remote" => Some("remote"),
3042
_ => None,
3143
}
3244
}
3345

34-
/// Canonicalizes a transport/`type` token before matching.
35-
///
36-
/// The MCP client ecosystem is inconsistent about casing: Cursor, Cline, and
37-
/// other clients emit `streamableHttp`, while others use `streamable-http`,
38-
/// `streamable_http`, `streamablehttp`, or `http`. Lowercasing before matching
39-
/// accepts every spelling without changing the canonical value we persist.
40-
pub(super) fn normalized_transport_token(value: &str) -> String {
41-
value.trim().to_ascii_lowercase()
42-
}
43-
4446
fn normalize_transport(value: &str) -> Option<&'static str> {
45-
match normalized_transport_token(value).as_str() {
47+
match normalized_token(value).as_str() {
4648
"stdio" => Some("stdio"),
4749
"sse" => Some("sse"),
4850
"http" | "streamable_http" | "streamable-http" | "streamablehttp" => {
@@ -53,7 +55,7 @@ fn normalize_transport(value: &str) -> Option<&'static str> {
5355
}
5456

5557
fn normalize_legacy_type(value: &str) -> Option<(Option<&'static str>, Option<&'static str>)> {
56-
match normalized_transport_token(value).as_str() {
58+
match normalized_token(value).as_str() {
5759
"stdio" => Some((None, Some("stdio"))),
5860
"local" => Some((Some("local"), Some("stdio"))),
5961
"sse" => Some((Some("remote"), Some("sse"))),

‎src/crates/services/services-integrations/tests/mcp_contracts.rs‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2251,7 +2251,12 @@ fn mcp_config_accepts_camel_case_streamable_http_type() {
22512251
Some(MCPServerTransport::StreamableHttp)
22522252
);
22532253

2254-
for alias in ["streamable-http", "streamable_http", "streamablehttp", "HTTP"] {
2254+
for alias in [
2255+
"streamable-http",
2256+
"streamable_http",
2257+
"streamablehttp",
2258+
"HTTP",
2259+
] {
22552260
validate_mcp_json_config(&serde_json::json!({
22562261
"mcpServers": {
22572262
"alias": { "type": alias, "url": "https://example.com/mcp" }
@@ -2260,3 +2265,64 @@ fn mcp_config_accepts_camel_case_streamable_http_type() {
22602265
.unwrap_or_else(|error| panic!("type '{}' must validate: {}", alias, error));
22612266
}
22622267
}
2268+
2269+
#[test]
2270+
fn mcp_config_normalizes_token_case_for_type_transport_and_source() {
2271+
// The visual editor lowercases `type`, `transport`, and `source` before
2272+
// matching. The core validator and parser must agree, otherwise a config
2273+
// the form renders happily fails again when the document is saved.
2274+
let cases = [
2275+
(
2276+
serde_json::json!({ "type": "StreamableHTTP", "url": "https://example.com/mcp" }),
2277+
"streamable-http",
2278+
MCPServerTransport::StreamableHttp,
2279+
),
2280+
(
2281+
serde_json::json!({
2282+
"transport": "STREAMABLE-HTTP",
2283+
"url": "https://example.com/mcp"
2284+
}),
2285+
"streamable-http",
2286+
MCPServerTransport::StreamableHttp,
2287+
),
2288+
(
2289+
serde_json::json!({
2290+
"source": "REMOTE",
2291+
"transport": "SSE",
2292+
"url": "https://example.com/sse"
2293+
}),
2294+
"sse",
2295+
MCPServerTransport::Sse,
2296+
),
2297+
(
2298+
serde_json::json!({ "source": "Local", "command": "npx", "args": ["-y", "server"] }),
2299+
"stdio",
2300+
MCPServerTransport::Stdio,
2301+
),
2302+
];
2303+
2304+
for (server, canonical_type, transport) in cases {
2305+
let config = serde_json::json!({ "mcpServers": { "case": server.clone() } });
2306+
2307+
validate_mcp_json_config(&config)
2308+
.unwrap_or_else(|error| panic!("'{}' must validate: {}", server, error));
2309+
2310+
let parsed = parse_cursor_format(&config);
2311+
assert_eq!(
2312+
parsed.len(),
2313+
1,
2314+
"'{}' must be parsed instead of silently dropped",
2315+
server
2316+
);
2317+
assert_eq!(parsed[0].transport, Some(transport), "for '{}'", server);
2318+
2319+
// Accepting a spelling must not change the canonical token we persist.
2320+
let written = config_to_cursor_format(&parsed[0]);
2321+
assert_eq!(
2322+
written["type"].as_str(),
2323+
Some(canonical_type),
2324+
"'{}' must persist the canonical token",
2325+
server
2326+
);
2327+
}
2328+
}

‎src/web-ui/src/infrastructure/config/components/mcpConfigForm.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ describe('MCP visual configuration compatibility', () => {
1919
{ source: 'remote', transport: 'streamable_http', url: 'https://example.test/mcp', timeouts: { startupMs: 12001, catalogMs: 7000, future: 23 } },
2020
{ type: ' remote ', transport: ' http ', source: ' remote ', url: 'https://example.test/mcp' },
2121
{ type: 'local', source: 'remote', transport: 'http', url: 'https://example.test/mcp' },
22+
{ type: 'streamableHttp', url: 'https://example.test/mcp' },
23+
{ source: 'REMOTE', transport: 'SSE', url: 'https://example.test/sse' },
2224
])('round-trips existing data without adding defaults or dropping fields: %j', entry => {
2325
const document = documentWith(entry);
2426
const original = JSON.stringify(document);

0 commit comments

Comments
 (0)