Skip to content

fix(mcp): validate server names with strict allowlist (fixes #1882) - #2400

Merged
ilblackdragon merged 7 commits into
stagingfrom
fix/1882-mcp-name-allowlist
Apr 18, 2026
Merged

ilblackdragon merged 7 commits into
stagingfrom
fix/1882-mcp-name-allowlist

Conversation

@serrrfirat

Copy link
Copy Markdown
Collaborator

Summary

  • Adds allowlist-based validation for MCP server names in McpServerConfig::validate(): only [a-zA-Z0-9_-] (alphanumeric, underscore, dash) are permitted
  • Prevents shell metacharacters (;, |, &, 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 tags
  • Changes load_mcp_servers_from() and load_mcp_servers_from_db() to skip invalid entries with a tracing::warn instead of failing the entire config load -- this prevents legacy names (e.g. "My Server") from disabling all MCP integrations after an upgrade
  • Adds comprehensive tests: valid names, shell injection attempts, path traversal, null bytes, dots, and a load-level test for graceful skip behavior

This supersedes #1941 and incorporates its review feedback:

  • Dots removed from the allowlist (LLM providers require tool names to match ^[a-zA-Z0-9_-]+$ and server names are used as tool name prefixes)
  • Graceful degradation on invalid names during config load (addresses @serrrfirat's migration/regression concern)

Test plan

  • cargo check passes
  • cargo clippy --all --all-features passes with zero warnings
  • All 47 MCP config tests pass including 7 new tests:
    • test_server_name_valid_characters_accepted
    • test_server_name_shell_metacharacters_rejected
    • test_server_name_path_separators_rejected
    • test_server_name_null_byte_rejected
    • test_server_name_dot_rejected
    • test_load_skips_invalid_server_names
    • test_load_skips_corrupted_headers (updated to verify skip behavior)

🤖 Generated with Claude Code

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>
@github-actions github-actions Bot added scope: tool/mcp MCP client size: M 50-199 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 13, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/tools/mcp/config.rs Outdated
Comment on lines +563 to +581
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
  1. In non-performance-critical code paths, prioritize code simplicity over micro-optimizations like avoiding clones, as the performance gain is often negligible.

Comment thread src/tools/mcp/config.rs Outdated
Comment on lines +661 to +676
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
  1. In non-performance-critical code paths, prioritize code simplicity over micro-optimizations like avoiding clones, as the performance gain is often negligible.

henrypark133
henrypark133 previously approved these changes Apr 14, 2026

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@github-actions github-actions Bot added size: L 200-499 changed lines and removed size: M 50-199 changed lines labels Apr 14, 2026
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>
@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Addressed review feedback from gemini-code-assist:

  • Both load_mcp_servers_from and load_mcp_servers_from_db: Replaced manual for-loop + intermediate McpServersFile struct with Vec::retain on the original config. This preserves schema_version implicitly and simplifies the filtering logic (−20/+14 lines).

All 254 MCP tests pass, including test_load_preserves_schema_version.

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

serrrfirat and others added 2 commits April 17, 2026 07:12
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>
@github-actions github-actions Bot added scope: channel/web Web gateway channel scope: channel/wasm WASM channel runtime labels Apr 17, 2026
henrypark133
henrypark133 previously approved these changes Apr 17, 2026

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ilblackdragon

Copy link
Copy Markdown
Member

@copilot resolve the merge conflicts in this pull request

@ilblackdragon
ilblackdragon merged commit 158f5f7 into staging Apr 18, 2026
16 checks passed
@ilblackdragon
ilblackdragon deleted the fix/1882-mcp-name-allowlist branch April 18, 2026 04:30
Copilot stopped work on behalf of ilblackdragon due to an error April 18, 2026 04:31
Copilot AI requested a review from ilblackdragon April 18, 2026 04:31
@ilblackdragon ilblackdragon mentioned this pull request Apr 18, 2026
5 of 7 tasks
ilblackdragon added a commit that referenced this pull request Apr 19, 2026
- 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.
ilblackdragon added a commit that referenced this pull request Apr 20, 2026
- 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.
ilblackdragon added a commit that referenced this pull request Apr 20, 2026
…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).
@henrypark133 henrypark133 mentioned this pull request Apr 21, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
) (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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: channel/wasm WASM channel runtime scope: channel/web Web gateway channel scope: tool/mcp MCP client size: L 200-499 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants