Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ 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<MCPServerType> {
match value.trim() {
match normalized_token(value).as_str() {
"local" => Some(MCPServerType::Local),
"remote" => Some(MCPServerType::Remote),
_ => None,
}
}

fn parse_transport(value: &str) -> Option<MCPServerTransport> {
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" => {
Expand All @@ -27,7 +27,7 @@ fn parse_transport(value: &str) -> Option<MCPServerTransport> {
}

fn parse_legacy_type(value: &str) -> Option<(Option<MCPServerType>, Option<MCPServerTransport>)> {
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))),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" => {
Expand All @@ -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"))),
Expand Down
68 changes: 67 additions & 1 deletion src/crates/services/services-integrations/tests/mcp_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading