fix(mcp): validate server names with strict allowlist (fixes #1882) - #2400
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>
…lost install-param extraction - Add #[allow(clippy::too_many_arguments)] to register_startup_channels - Restore tool_install/tool_activate/tool_auth parameter extraction block in pending_gate_extension_name that was lost during staging merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 load path now filters invalid legacy entries while preserving the rest of the config.
|
@copilot resolve the merge conflicts in this pull request |
- src/bridge/router.rs response DTO now uses engine's MissionId(Uuid) instead of String - McpServerName newtype in ironclaw_common encapsulates the allowlist validation added in #2400 Closes the type gap for two identifiers flagged in the recent audit of string-typed values in the system.
- src/bridge/router.rs response DTO now uses engine's MissionId(Uuid) instead of String - McpServerName newtype in ironclaw_common encapsulates the allowlist validation added in #2400 Closes the type gap for two identifiers flagged in the recent audit of string-typed values in the system.
…2681) * refactor(types): adopt MissionId in router + introduce McpServerName - src/bridge/router.rs response DTO now uses engine's MissionId(Uuid) instead of String - McpServerName newtype in ironclaw_common encapsulates the allowlist validation added in #2400 Closes the type gap for two identifiers flagged in the recent audit of string-typed values in the system. * refactor(mcp): address review feedback — validate server names at construction - McpClient::new and new_with_name: validate through McpServerName::new with a canonical "unknown" fallback + debug log, instead of from_trusted. Closes two HIGH-severity allowlist bypasses (e.g. IPv6 bracketed hosts and caller-supplied bad names). - McpClient::new: apply the same hyphen→underscore fold as the other constructors (only when a hyphen is present) for consistency. - identity.rs: extract validate_mcp_server_name() helper so TryFrom<String> consumes the owned buffer without re-allocating; MAX_MCP_SERVER_NAME_LEN aliased to MAX_NAME_LEN to prevent drift. - factory.rs: capture validated McpServerName and thread .as_str() through hottest downstream uses; TODO left for full threading. - Adds regression tests covering the IPv6-host and invalid-caller-name paths against the caller (McpClient::new / new_with_name). * fix(mcp): annotate panic-safe McpServerName::new("unknown") .expect() calls The no-panics CI check flagged two `.expect()` calls on `McpServerName::new("unknown")` fallbacks inside `McpClient::new` and `McpClient::new_with_name`. The literal `"unknown"` always satisfies the alnum-only validation rule, so the call is infallible. Document that with an inline `// safety: ...` comment per the project's panic-check suppression convention. * refactor(mcp): validate HttpMcpTransport::new server_name with safe fallback * refactor(mcp): validate McpClient constructor server names with safe fallback Address PR #2681 Copilot review comments 3108427003, 3108427048, and 3108427073. The three remaining constructors (`new_with_transport`, `new_with_config`, `new_authenticated`) were wrapping caller-provided names with `McpServerName::from_trusted`, allowing invalid values to enter the typed field. Beyond bypassing the allowlist, the `_with_config` and `_authenticated` paths also diverged from `HttpMcpTransport::new`, which independently validates and falls back to `"unknown"` — so an invalid name could leave the client keyed one way and the transport another, silently breaking `Mcp-Session-Id` tracking. Each constructor now runs the same canonicalize-with-fallback pattern used by `McpClient::new`, `new_with_name`, and `HttpMcpTransport::new`, and threads the single validated name into both the transport and the client's typed field so the two cannot diverge. Regression tests added (per .claude/rules/testing.md, "Test Through the Caller"): invalid config/transport inputs fall back to "unknown"; valid inputs survive; client and transport names agree for both cases. * fix(mcp): migrate legacy overlong server names at load instead of dropping Address PR #2681 review comment 3110617080. Before the `McpServerName` newtype landed, `McpServerConfig::validate()` only enforced non-empty + `[A-Za-z0-9_-]`. Delegating validation to `McpServerName::new` added a 64-byte length cap — and `load_mcp_servers_from*` silently dropped invalid configs via `retain(...)`, so a legacy persisted server name >64 bytes would vanish from the loaded config on upgrade. The load paths now in-place truncate overlong names at a char boundary (safe even if the pre-validation string contains multi-byte UTF-8), emit a `warn!` documenting the migration, and let the (now cap-satisfying) entry pass validation. Invalid-char cases still drop via `retain`, matching the pre-PR behavior for that class. Regression tests cover both the happy path (ASCII overlong name kept, truncated to exactly the cap) and char-boundary safety (multi-byte sequence straddling byte 64 must not panic).
) (nearai#2400) * fix(mcp): validate server names with strict allowlist (fixes nearai#1882) 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 nearai#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> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mcp): preserve schema_version when filtering invalid servers 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> * refactor(mcp): use Vec::retain for server filtering 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> * fix: resolve CI failures — clippy too_many_arguments + restore merge-lost install-param extraction - Add #[allow(clippy::too_many_arguments)] to register_startup_channels - Restore tool_install/tool_activate/tool_auth parameter extraction block in pending_gate_extension_name that was lost during staging merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Zaki <zaki@iqlusion.io> Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
…earai#2681) * refactor(types): adopt MissionId in router + introduce McpServerName - src/bridge/router.rs response DTO now uses engine's MissionId(Uuid) instead of String - McpServerName newtype in ironclaw_common encapsulates the allowlist validation added in nearai#2400 Closes the type gap for two identifiers flagged in the recent audit of string-typed values in the system. * refactor(mcp): address review feedback — validate server names at construction - McpClient::new and new_with_name: validate through McpServerName::new with a canonical "unknown" fallback + debug log, instead of from_trusted. Closes two HIGH-severity allowlist bypasses (e.g. IPv6 bracketed hosts and caller-supplied bad names). - McpClient::new: apply the same hyphen→underscore fold as the other constructors (only when a hyphen is present) for consistency. - identity.rs: extract validate_mcp_server_name() helper so TryFrom<String> consumes the owned buffer without re-allocating; MAX_MCP_SERVER_NAME_LEN aliased to MAX_NAME_LEN to prevent drift. - factory.rs: capture validated McpServerName and thread .as_str() through hottest downstream uses; TODO left for full threading. - Adds regression tests covering the IPv6-host and invalid-caller-name paths against the caller (McpClient::new / new_with_name). * fix(mcp): annotate panic-safe McpServerName::new("unknown") .expect() calls The no-panics CI check flagged two `.expect()` calls on `McpServerName::new("unknown")` fallbacks inside `McpClient::new` and `McpClient::new_with_name`. The literal `"unknown"` always satisfies the alnum-only validation rule, so the call is infallible. Document that with an inline `// safety: ...` comment per the project's panic-check suppression convention. * refactor(mcp): validate HttpMcpTransport::new server_name with safe fallback * refactor(mcp): validate McpClient constructor server names with safe fallback Address PR nearai#2681 Copilot review comments 3108427003, 3108427048, and 3108427073. The three remaining constructors (`new_with_transport`, `new_with_config`, `new_authenticated`) were wrapping caller-provided names with `McpServerName::from_trusted`, allowing invalid values to enter the typed field. Beyond bypassing the allowlist, the `_with_config` and `_authenticated` paths also diverged from `HttpMcpTransport::new`, which independently validates and falls back to `"unknown"` — so an invalid name could leave the client keyed one way and the transport another, silently breaking `Mcp-Session-Id` tracking. Each constructor now runs the same canonicalize-with-fallback pattern used by `McpClient::new`, `new_with_name`, and `HttpMcpTransport::new`, and threads the single validated name into both the transport and the client's typed field so the two cannot diverge. Regression tests added (per .claude/rules/testing.md, "Test Through the Caller"): invalid config/transport inputs fall back to "unknown"; valid inputs survive; client and transport names agree for both cases. * fix(mcp): migrate legacy overlong server names at load instead of dropping Address PR nearai#2681 review comment 3110617080. Before the `McpServerName` newtype landed, `McpServerConfig::validate()` only enforced non-empty + `[A-Za-z0-9_-]`. Delegating validation to `McpServerName::new` added a 64-byte length cap — and `load_mcp_servers_from*` silently dropped invalid configs via `retain(...)`, so a legacy persisted server name >64 bytes would vanish from the loaded config on upgrade. The load paths now in-place truncate overlong names at a char boundary (safe even if the pre-validation string contains multi-byte UTF-8), emit a `warn!` documenting the migration, and let the (now cap-satisfying) entry pass validation. Invalid-char cases still drop via `retain`, matching the pre-PR behavior for that class. Regression tests cover both the happy path (ASCII overlong name kept, truncated to exactly the cap) and char-boundary safety (multi-byte sequence straddling byte 64 must not panic).
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