Skip to content

fix(mcp): normalize server names, fix UTF-8 truncation, skip auth when header set#2379

Open
zmanian wants to merge 1 commit intostagingfrom
fix/mcp-client-bugs
Open

fix(mcp): normalize server names, fix UTF-8 truncation, skip auth when header set#2379
zmanian wants to merge 1 commit intostagingfrom
fix/mcp-client-bugs

Conversation

@zmanian
Copy link
Copy Markdown
Collaborator

@zmanian zmanian commented Apr 12, 2026

Summary

Fixes three related MCP client bugs in a single PR:

Test plan

  • test_normalize_server_name_* - 5 tests covering basic, uppercase, spaces/special chars, empty/all-special, and leading/trailing underscores
  • test_tool_description_truncation_multibyte_utf8 - regression test with CJK characters where byte index 57 falls mid-character
  • test_tool_description_truncation_ascii - ASCII descriptions still truncate correctly
  • test_requires_auth_skipped_with_custom_auth_header - Authorization header skips auth
  • test_requires_auth_skipped_with_lowercase_auth_header - case-insensitive check
  • test_requires_auth_still_true_with_non_auth_headers - non-Authorization headers don't suppress auth
  • cargo clippy --all --all-features passes with zero warnings
  • All 43 MCP config tests + 9 CLI MCP tests pass

Closes #2236, closes #1947, closes #1948

Generated with Claude Code

@github-actions github-actions bot added size: XL 500+ changed lines scope: channel Channel infrastructure scope: channel/cli TUI / CLI channel scope: channel/web Web gateway channel scope: tool Tool infrastructure scope: tool/builtin Built-in tools scope: tool/mcp MCP client scope: dependencies Dependency updates risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs and removed size: XL 500+ changed lines labels Apr 12, 2026
Copy link
Copy Markdown
Contributor

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

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 Ethereum wallet integration via WalletConnect v2, adding tools for wallet pairing and transaction submission. It also includes several bug fixes for MCP server name normalization, UTF-8 safe string truncation, and OAuth flow logic. However, the current implementation has critical architectural issues: holding a MutexGuard across an await point in the event loop will cause deadlocks, and the wallet_transact tool is currently non-functional as it registers a callback without actually initiating the transaction request. Additionally, git dependencies should be pinned to specific commit hashes, and account address extraction should avoid relying on Debug formatting.

Comment on lines +77 to +79
if let Some(ref wc) = *client {
match wc.next().await {
Ok(event) => event,
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.

critical

The MutexGuard is held across an .await point in poll_event. Since wc.next().await can block indefinitely while waiting for events from the relay, this will prevent any other task from acquiring the lock. This effectively deadlocks other operations like request(), which also needs to acquire the same lock. Consider an architecture where the client is not locked during long-running awaits, or use a split reader/writer pattern if supported by the underlying library.

Comment on lines +104 to +170
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();

// Must be paired first.
if !self.session.is_paired().await {
return Err(EthereumError::NotPaired.into());
}

// Extract and validate parameters.
let to = require_str(&params, "to")?;
if !is_valid_eth_address(to) {
return Err(EthereumError::InvalidAddress {
address: to.to_string(),
}
.into());
}

let value = require_str(&params, "value")?;
let data = params.get("data").and_then(|v| v.as_str());
let chain_id = params.get("chain_id").and_then(|v| v.as_u64());

// Generate a correlation ID for the async callback.
let correlation_id = uuid::Uuid::new_v4().to_string();

// Extract channel/thread from context metadata for callback routing.
let channel = ctx
.metadata
.get("source_channel")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let thread_id = ctx
.metadata
.get("thread_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());

// Register callback so the result can be routed back.
self.callback_registry
.register(
correlation_id.clone(),
CallbackMetadata {
tool_name: "wallet_transact".to_string(),
user_id: ctx.user_id.clone(),
thread_id,
channel,
},
)
.await;

let mut result = json!({
"status": "pending",
"correlation_id": correlation_id,
"to": to,
"value": value,
"message": "Transaction submitted to wallet for approval. \
The wallet owner must confirm on their device."
});

if data.is_some() {
result["data_present"] = json!(true);
}
if let Some(cid) = chain_id {
result["chain_id"] = json!(cid);
}

Ok(ToolOutput::success(result, start.elapsed()))
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.

critical

The wallet_transact tool registers a callback and returns a pending status, but it never actually initiates the transaction request via self.session.request(). This makes the tool non-functional as the wallet will never receive the signature request. The tool should initiate the request (potentially in a background task if it blocks) before returning the correlation ID.

Cargo.toml Outdated
hex = "0.4.3"

# WalletConnect v2 client for Ethereum transaction approval
walletconnect-client = { git = "https://github.com/zmanian/walletconnect-client.git", branch = "main", default-features = false, features = ["native"] }
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

Pin git dependencies to a specific commit hash (rev) for reproducible builds, as per the general rules of this repository.

References
  1. Pin git dependencies to a specific commit hash (rev) for reproducible builds.

Comment on lines +79 to +80
Ok(event) => event,
Err(e) => {
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

When wc.next().await returns Ok(None), it indicates that the stream has ended. The current implementation returns None but leaves the client in the Option, causing the listener loop to enter a busy-wait state (polling every 100ms and getting None immediately). The client should be cleared (*client = None) when the stream ends to stop the polling.

if let Some(ref wc) = *client {
let address = wc
.get_account()
.map(|a| format!("{a:?}"))
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

Using format!("{a:?}") to extract the account address is unreliable as it depends on the Debug implementation of the AccountId type, which often includes the chain ID prefix (e.g., eip155:1:0x...). It is better to use a specific method to extract just the address string if available in the walletconnect-client API.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b397d21bd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +142 to +146
self.callback_registry
.register(
correlation_id.clone(),
CallbackMetadata {
tool_name: "wallet_transact".to_string(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Submit wallet transaction before returning pending status

wallet_transact currently records callback metadata and returns a pending success payload, but never calls WalletConnectSession::request (or any other dispatch path) to actually send eth_sendTransaction. In this commit there is no production code path that consumes the correlation ID to submit the transaction, so users can receive a “submitted for approval” response while nothing was sent and the callback only times out.

Useful? React with 👍 / 👎.

Comment on lines +83 to +85
if matches!(e, WalletConnectError::Disconnected) {
*client = None;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset paired state when WalletConnect drops with error

When wc.next() returns WalletConnectError::Disconnected, the code clears wc_client but does not update status, so the session can remain Paired even though no client exists. In that state, pairing/transaction flows can behave inconsistently (e.g., appearing paired first, then failing later), and users may be blocked from cleanly re-pairing after a disconnect.

Useful? React with 👍 / 👎.

…th when header set (#2236, #1947, #1948)

- Server names are now auto-normalized (lowercase, special chars to
  underscores) so descriptive names like "My Twitter Server" are
  accepted as "my_twitter_server" instead of rejected.

- Tool description truncation in `mcp test-server` uses char-safe
  iteration instead of byte-index slicing, preventing panics when
  index 57 falls inside a multi-byte UTF-8 character.

- `requires_auth()` now returns false when the user has already
  configured a custom Authorization header, avoiding unnecessary
  OAuth/DCR flows for API-key-authenticated servers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zmanian zmanian force-pushed the fix/mcp-client-bugs branch from 4b397d2 to 95c41b3 Compare April 12, 2026 23:23
@github-actions github-actions bot added the size: L 200-499 changed lines label Apr 12, 2026
// Truncate long descriptions (char-safe to avoid
// panicking on multi-byte UTF-8 boundaries, #1947)
let desc = if tool.description.chars().count() > 60 {
let truncated: String = tool.description.chars().take(57).collect();
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.

Critical — Pre-existing byte-slicing panic (same pattern)

This PR fixes byte-level truncation in test_server, but crates/ironclaw_tui/src/widgets/conversation.rs:863 has the same pattern: .truncate(57) which is byte-based and will panic on multi-byte UTF-8. The PR's own philosophy ("fix the pattern") demands fixing all instances.

Suggested fix: Also fix items_str.truncate(57) in conversation.rs to use chars().take() or floor_char_boundary(), matching the pattern used in this PR.

///
/// This allows users to supply descriptive names like "My Twitter Server"
/// which are stored as `my_twitter_server` (#2236).
pub fn normalize_server_name(name: &str) -> String {
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.

High — Server-side API handler bypasses name normalization

src/channels/web/handlers/extensions.rs:143 passes req.name directly to ext_mgr.install() without normalization. The JS frontend normalizes on the client side, but direct API calls (e.g., curl) bypass the JS and send raw names. This creates client-server divergence.

Suggested fix: Apply normalize_server_name() on the server side in extensions_install_handler for mcp_server kind, or add name format validation to McpServerConfig::validate().

anyhow::bail!("Server name '{}' contains no valid characters", raw_name);
}
if name != raw_name {
println!(" (normalized name: '{}' -> '{}')", raw_name, name);
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.

Medium — Lookup commands don't normalize names

remove_server, auth_server, test_server, and toggle_server do NOT normalize the user-provided name. If a user adds "My Twitter Server" (stored as my_twitter_server), then runs ironclaw mcp test "My Twitter Server", the lookup fails with "Server not found" and no hint about the normalized name.

Suggested fix: Either normalize in lookup commands too (with a "did you mean 'my_twitter_server'?" fallback), or include the normalized form in the error message when lookup fails.

/// byte index 57 falls inside a character boundary would panic with
/// `&tool.description[..57]`.
#[test]
fn test_tool_description_truncation_multibyte_utf8() {
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.

Medium — Tests duplicate logic instead of testing production code

The truncation regression tests (test_tool_description_truncation_multibyte_utf8, test_tool_description_truncation_ascii) duplicate the truncation logic inline rather than calling the actual production code path. If someone changes the production truncation logic (e.g., changes 57 to 50), these tests would still pass.

Suggested fix: Extract the truncation logic into a helper function used by both test_server and the tests, or write the tests to call test_server with a mock client.

// Trim trailing underscore
if result.ends_with('_') {
result.pop();
}
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.

Medium — Leading/trailing hyphens not trimmed

normalize_server_name trims leading/trailing underscores but not hyphens. Input "-hello-" produces "-hello-", and consecutive hyphens are not collapsed. Names like --- would produce ---, which could confuse downstream systems using the name in file paths or secret keys (e.g., mcp_---_access_token).

Suggested fix: Treat hyphens symmetrically: also collapse consecutive hyphens and trim leading/trailing hyphens, or document the intentional asymmetry. At minimum, add test cases for "-hello-", "---", and "a--b" to pin the behavior.

Copy link
Copy Markdown
Collaborator

@serrrfirat serrrfirat left a comment

Choose a reason for hiding this comment

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

Paranoid Architect Review — REQUEST CHANGES

1 Critical, 1 High, 3 Medium findings.

The three bug fixes (normalization, UTF-8 truncation, auth skip) are solid and well-tested. However:

  • Critical: A pre-existing .truncate(57) byte-slicing panic exists in conversation.rs — the same pattern this PR fixes. Fix the pattern, not just the instance.
  • High: The server-side API handler bypasses name normalization, so direct API calls can inject unnormalized names.
  • Medium: Lookup commands don't normalize, tests duplicate logic instead of testing production code, and hyphens are treated asymmetrically with underscores.

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/cli TUI / CLI channel scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: dependencies Dependency updates scope: tool/builtin Built-in tools scope: tool/mcp MCP client scope: tool Tool infrastructure size: L 200-499 changed lines

Projects

None yet

2 participants