From e7a7ade48004b38997e2b3c8c55594d885292db7 Mon Sep 17 00:00:00 2001 From: Misaka0x26FE Date: Sun, 20 Sep 2026 18:55:38 +0800 Subject: [PATCH] fix(mcp): accept camelCase streamableHttp transport type Cursor, Cline, and other MCP clients emit type: streamableHttp, but OpenBitFun's config validators only matched http, streamable_http, streamable-http, and streamablehttp. Normalize the transport token with trim + ASCII lowercase before matching so every common spelling resolves to the Streamable HTTP transport. Fixes #3155 --- src/apps/cli/src/modes/chat/mcp.rs | 11 +++++-- .../src/mcp/config/cursor_format.rs | 5 +-- .../src/mcp/config/json_config.rs | 14 ++++++-- .../tests/mcp_contracts.rs | 33 +++++++++++++++++++ .../config/components/mcpConfigForm.ts | 8 +++-- 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/apps/cli/src/modes/chat/mcp.rs b/src/apps/cli/src/modes/chat/mcp.rs index 9dd47f0544..5ac2f151fe 100644 --- a/src/apps/cli/src/modes/chat/mcp.rs +++ b/src/apps/cli/src/modes/chat/mcp.rs @@ -696,9 +696,16 @@ impl ChatMode { }) .unwrap_or_default() }; - let transport = match config.get("type").and_then(serde_json::Value::as_str) { + let transport = match config + .get("type") + .and_then(serde_json::Value::as_str) + .map(|value| value.trim().to_ascii_lowercase()) + .as_deref() + { Some("sse") => McpTransport::Sse, - Some("streamable-http" | "streamable_http" | "http") => McpTransport::StreamableHttp, + Some("streamable-http" | "streamable_http" | "streamablehttp" | "http") => { + McpTransport::StreamableHttp + } _ => McpTransport::Stdio, }; let mutation = McpServerMutation { 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 f4c0f4bcc4..42adadcb7e 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,6 +4,7 @@ use log::warn; use crate::mcp::server::{MCPServerConfig, MCPServerTransport, MCPServerType}; +use super::json_config::normalized_transport_token; use super::ConfigLocation; fn parse_source(value: &str) -> Option { @@ -15,7 +16,7 @@ fn parse_source(value: &str) -> Option { } fn parse_transport(value: &str) -> Option { - match value.trim() { + match normalized_transport_token(value).as_str() { "stdio" => Some(MCPServerTransport::Stdio), "sse" => Some(MCPServerTransport::Sse), "http" | "streamable_http" | "streamable-http" | "streamablehttp" => { @@ -26,7 +27,7 @@ fn parse_transport(value: &str) -> Option { } fn parse_legacy_type(value: &str) -> Option<(Option, Option)> { - match value.trim() { + match normalized_transport_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 20ea6ab6e7..753c1d39f2 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 @@ -31,8 +31,18 @@ fn normalize_source(value: &str) -> Option<&'static str> { } } +/// 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 value.trim() { + match normalized_transport_token(value).as_str() { "stdio" => Some("stdio"), "sse" => Some("sse"), "http" | "streamable_http" | "streamable-http" | "streamablehttp" => { @@ -43,7 +53,7 @@ fn normalize_transport(value: &str) -> Option<&'static str> { } fn normalize_legacy_type(value: &str) -> Option<(Option<&'static str>, Option<&'static str>)> { - match value.trim() { + match normalized_transport_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 920e5110db..2ddf1e72c4 100644 --- a/src/crates/services/services-integrations/tests/mcp_contracts.rs +++ b/src/crates/services/services-integrations/tests/mcp_contracts.rs @@ -2227,3 +2227,36 @@ fn mcp_cursor_format_helpers_preserve_cursor_compatibility_contract() { assert_eq!(parsed[0].transport, Some(MCPServerTransport::Sse)); assert_eq!(parsed[0].location, ConfigLocation::User); } + +#[test] +fn mcp_config_accepts_camel_case_streamable_http_type() { + // Cursor, Cline, and other MCP clients emit `type: "streamableHttp"`. + // OpenBitFun must accept it (and other casings) as streamable HTTP. + let config = serde_json::json!({ + "mcpServers": { + "remote": { + "type": "streamableHttp", + "url": "https://example.com/mcp" + } + } + }); + + validate_mcp_json_config(&config).expect("camelCase streamableHttp type must validate"); + + let parsed = parse_cursor_format(&config); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].server_type, MCPServerType::Remote); + assert_eq!( + parsed[0].transport, + Some(MCPServerTransport::StreamableHttp) + ); + + 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" } + } + })) + .unwrap_or_else(|error| panic!("type '{}' must validate: {}", alias, error)); + } +} diff --git a/src/web-ui/src/infrastructure/config/components/mcpConfigForm.ts b/src/web-ui/src/infrastructure/config/components/mcpConfigForm.ts index 92a2964a03..05a1be28d4 100644 --- a/src/web-ui/src/infrastructure/config/components/mcpConfigForm.ts +++ b/src/web-ui/src/infrastructure/config/components/mcpConfigForm.ts @@ -90,9 +90,11 @@ function transportFromConfig(entry: McpConfigObject): McpServerDraft['transport' 'streamable-http': 'streamable-http', streamable_http: 'streamable-http', streamablehttp: 'streamable-http', sse: 'sse', }; - const type = optionalString(entry, 'type').trim(); - const explicitTransport = optionalString(entry, 'transport').trim(); - const explicitSource = optionalString(entry, 'source').trim(); + // Lowercase before lookup so camelCase spellings emitted by other clients + // (for example `streamableHttp`) match the canonical aliases below. + const type = optionalString(entry, 'type').trim().toLowerCase(); + const explicitTransport = optionalString(entry, 'transport').trim().toLowerCase(); + const explicitSource = optionalString(entry, 'source').trim().toLowerCase(); if ((type && !Object.prototype.hasOwnProperty.call(aliases, type)) || (explicitTransport && (!Object.prototype.hasOwnProperty.call(aliases, explicitTransport) || explicitTransport === 'local' || explicitTransport === 'remote'))