fix(mcp): validate server names with strict allowlist (fixes #1882)#2400
fix(mcp): validate server names with strict allowlist (fixes #1882)#2400serrrfirat wants to merge 5 commits intostagingfrom
Conversation
MCP server names are interpolated into secret keys, tool name prefixes, and provider tags. Without validation, shell metacharacters (;|&`$), path separators (/\), dots, and other special characters in server names could enable injection attacks. This adds an allowlist-based check in McpServerConfig::validate() that only permits [a-zA-Z0-9_-]. Dots are excluded because LLM providers require tool names to match ^[a-zA-Z0-9_-]+$ and server names are used as tool name prefixes. To avoid breaking users with legacy names (e.g. "My Server"), load_mcp_servers_from() and load_mcp_servers_from_db() now skip invalid entries with a tracing::warn instead of failing the entire config load. Supersedes #1941 and incorporates its review feedback (removing dots from the allowlist, graceful degradation on invalid names). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces stricter validation for MCP server names, allowing only alphanumeric characters, dashes, and underscores to prevent injection vulnerabilities and ensure compatibility with LLM providers. It also updates the configuration loading logic for both disk and database sources to skip invalid server entries with a warning rather than failing the entire load process. Feedback suggests using Vec::retain on the existing configuration object when filtering invalid servers to ensure that metadata, such as the schema_version, is preserved instead of being reset to default values.
| let config: McpServersFile = serde_json::from_str(&content)?; | ||
|
|
||
| // Validate every server on load so corrupted configs are caught early | ||
| // Validate every server on load. Invalid entries are skipped with a | ||
| // warning instead of failing the entire config — this prevents legacy | ||
| // names (e.g. "My Server") from disabling all MCP integrations after | ||
| // an upgrade that tightened validation. | ||
| let mut valid = McpServersFile::default(); | ||
| for server in &config.servers { | ||
| server.validate().map_err(|e| ConfigError::InvalidConfig { | ||
| reason: format!("Server '{}': {}", server.name, e), | ||
| })?; | ||
| if let Err(e) = server.validate() { | ||
| tracing::warn!( | ||
| server_name = %server.name, | ||
| "Skipping MCP server with invalid config: {e}" | ||
| ); | ||
| continue; | ||
| } | ||
| valid.servers.push(server.clone()); | ||
| } | ||
|
|
||
| Ok(config) | ||
| Ok(valid) |
There was a problem hiding this comment.
The current implementation of load_mcp_servers_from creates a new McpServersFile using Default::default(), which resets the schema_version to its default value (1). This loses any versioning metadata present in the original configuration file. Using retain on the original config.servers vector is a cleaner way to filter invalid entries while preserving the rest of the struct's metadata.
| let config: McpServersFile = serde_json::from_str(&content)?; | |
| // Validate every server on load so corrupted configs are caught early | |
| // Validate every server on load. Invalid entries are skipped with a | |
| // warning instead of failing the entire config — this prevents legacy | |
| // names (e.g. "My Server") from disabling all MCP integrations after | |
| // an upgrade that tightened validation. | |
| let mut valid = McpServersFile::default(); | |
| for server in &config.servers { | |
| server.validate().map_err(|e| ConfigError::InvalidConfig { | |
| reason: format!("Server '{}': {}", server.name, e), | |
| })?; | |
| if let Err(e) = server.validate() { | |
| tracing::warn!( | |
| server_name = %server.name, | |
| "Skipping MCP server with invalid config: {e}" | |
| ); | |
| continue; | |
| } | |
| valid.servers.push(server.clone()); | |
| } | |
| Ok(config) | |
| Ok(valid) | |
| let mut config: McpServersFile = serde_json::from_str(&content)?; | |
| // Validate every server on load. Invalid entries are skipped with a | |
| // warning instead of failing the entire config — this prevents legacy | |
| // names (e.g. "My Server") from disabling all MCP integrations after | |
| // an upgrade that tightened validation. | |
| config.servers.retain(|server| { | |
| if let Err(e) = server.validate() { | |
| tracing::warn!( | |
| server_name = %server.name, | |
| "Skipping MCP server with invalid config: {e}" | |
| ); | |
| false | |
| } else { | |
| true | |
| } | |
| }); | |
| Ok(config) |
References
- In non-performance-critical code paths, prioritize code simplicity over micro-optimizations like avoiding clones, as the performance gain is often negligible.
| let config: McpServersFile = serde_json::from_value(value)?; | ||
| // Validate every server on load so corrupted DB configs are caught early | ||
| // Validate every server on load. Invalid entries are skipped | ||
| // with a warning to avoid breaking all MCP integrations when | ||
| // legacy names don't pass tightened validation. | ||
| let mut valid = McpServersFile::default(); | ||
| for server in &config.servers { | ||
| server.validate().map_err(|e| ConfigError::InvalidConfig { | ||
| reason: format!("Server '{}': {}", server.name, e), | ||
| })?; | ||
| if let Err(e) = server.validate() { | ||
| tracing::warn!( | ||
| server_name = %server.name, | ||
| "Skipping MCP server with invalid DB config: {e}" | ||
| ); | ||
| continue; | ||
| } | ||
| valid.servers.push(server.clone()); | ||
| } | ||
| Ok(config) | ||
| Ok(valid) |
There was a problem hiding this comment.
Similar to load_mcp_servers_from, this implementation resets the schema_version by creating a new McpServersFile. Using retain on the existing config.servers preserves metadata and keeps the logic straightforward.
| let config: McpServersFile = serde_json::from_value(value)?; | |
| // Validate every server on load so corrupted DB configs are caught early | |
| // Validate every server on load. Invalid entries are skipped | |
| // with a warning to avoid breaking all MCP integrations when | |
| // legacy names don't pass tightened validation. | |
| let mut valid = McpServersFile::default(); | |
| for server in &config.servers { | |
| server.validate().map_err(|e| ConfigError::InvalidConfig { | |
| reason: format!("Server '{}': {}", server.name, e), | |
| })?; | |
| if let Err(e) = server.validate() { | |
| tracing::warn!( | |
| server_name = %server.name, | |
| "Skipping MCP server with invalid DB config: {e}" | |
| ); | |
| continue; | |
| } | |
| valid.servers.push(server.clone()); | |
| } | |
| Ok(config) | |
| Ok(valid) | |
| let mut config: McpServersFile = serde_json::from_value(value)?; | |
| // Validate every server on load. Invalid entries are skipped | |
| // with a warning to avoid breaking all MCP integrations when | |
| // legacy names don't pass tightened validation. | |
| config.servers.retain(|server| { | |
| if let Err(e) = server.validate() { | |
| tracing::warn!( | |
| server_name = %server.name, | |
| "Skipping MCP server with invalid DB config: {e}" | |
| ); | |
| false | |
| } else { | |
| true | |
| } | |
| }); | |
| Ok(config) |
References
- In non-performance-critical code paths, prioritize code simplicity over micro-optimizations like avoiding clones, as the performance gain is often negligible.
henrypark133
left a comment
There was a problem hiding this comment.
Review: Validate MCP server names with strict allowlist (Risk: Medium)
Well-done security fix with pragmatic error handling.
Positives:
- Strict name validation: only alphanumeric, dash, underscore — prevents shell metacharacters, path separators, dots, and null bytes
- Graceful degradation: invalid entries are skipped with a warning instead of failing the entire config — prevents legacy names from disabling all MCP integrations after an upgrade
- Both file and DB loading paths updated consistently
- Comprehensive test suite: valid names, shell metacharacters, path separators, null bytes, dots, and the skip-invalid-entries integration test
Convention notes:
- Good comment explaining why dots are rejected (LLM provider tool name prefix constraint)
tracing::warn!is appropriate here since this is user-facing startup validation, not background task diagnostics
LGTM.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
McpServersFile::default() sets schema_version to 0 (u32::default), but configs loaded from JSON get schema_version 1 via serde default. The server-filtering logic in load_mcp_servers_from() and load_mcp_servers_from_db() was using McpServersFile::default(), silently downgrading schema_version from 1 to 0 on every load-filter-save cycle (e.g. via bootstrap_nearai_mcp_server). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace manual for-loop with `retain` as suggested in review — simpler and avoids constructing a new McpServersFile struct. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Addressed review feedback from gemini-code-assist:
All 254 MCP tests pass, including |
henrypark133
left a comment
There was a problem hiding this comment.
Review: MCP server name allowlist
No verified findings in the current diff. The stricter name validation is applied on construction and on config load, and the follow-up change preserves schema_version while filtering invalid legacy entries instead of breaking the whole MCP config.
Resolve conflict in src/tools/mcp/config.rs — keep both PR's name-validation tests and staging's env-config test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
McpServerConfig::validate(): only[a-zA-Z0-9_-](alphanumeric, underscore, dash) are permitted;,|,&, backticks,$()), path separators (/,\), dots, null bytes, spaces, and other dangerous characters that could cause injection when names are interpolated into secret keys, tool name prefixes, or provider tagsload_mcp_servers_from()andload_mcp_servers_from_db()to skip invalid entries with atracing::warninstead of failing the entire config load -- this prevents legacy names (e.g. "My Server") from disabling all MCP integrations after an upgradeThis supersedes #1941 and incorporates its review feedback:
^[a-zA-Z0-9_-]+$and server names are used as tool name prefixes)Test plan
cargo checkpassescargo clippy --all --all-featurespasses with zero warningstest_server_name_valid_characters_acceptedtest_server_name_shell_metacharacters_rejectedtest_server_name_path_separators_rejectedtest_server_name_null_byte_rejectedtest_server_name_dot_rejectedtest_load_skips_invalid_server_namestest_load_skips_corrupted_headers(updated to verify skip behavior)🤖 Generated with Claude Code