Skip to content
Open
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
16 changes: 14 additions & 2 deletions crates/ironclaw_gateway/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6520,12 +6520,24 @@ function installWasmExtension() {
});
}

function normalizeMcpServerName(raw) {
return raw.toLowerCase()
.replace(/[^a-z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
}

function addMcpServer() {
var name = document.getElementById('mcp-install-name').value.trim();
if (!name) {
var rawName = document.getElementById('mcp-install-name').value.trim();
if (!rawName) {
showToast(I18n.t('mcp.serverNameRequired'), 'error');
return;
}
var name = normalizeMcpServerName(rawName);
if (!name) {
showToast('Server name contains no valid characters', 'error');
return;
}
var url = document.getElementById('mcp-install-url').value.trim();
if (!url) {
showToast(I18n.t('mcp.urlRequired'), 'error');
Expand Down
9 changes: 8 additions & 1 deletion crates/ironclaw_tui/src/widgets/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,14 @@ impl ConversationWidget {
let mut items_str = items.join(", ");
// Truncate if too long
if items_str.len() > 60 {
items_str.truncate(57);
// Use char_indices to find a safe truncation point (UTF-8 safe)
let truncate_at = items_str
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= 57)
.last()
.unwrap_or(0);
items_str.truncate(truncate_at);
items_str.push_str("...");
}

Expand Down
7 changes: 5 additions & 2 deletions src/agent/thread_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,9 @@ impl Agent {
.iter()
.any(|rule| rule.action == ironclaw_safety::PolicyAction::Block)
{
return Ok(SubmissionResult::error("Input rejected by safety policy."));
return Ok(SubmissionResult::error(
"Input rejected by safety policy.",
));
}
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
Expand All @@ -426,7 +428,8 @@ impl Agent {
// acknowledgment, not a completed LLM turn.
return Ok(SubmissionResult::Ok {
message: Some(
"Message queued — will be processed after the current turn.".into(),
"Message queued — will be processed after the current turn."
.into(),
),
});
}
Expand Down
13 changes: 12 additions & 1 deletion src/channels/web/handlers/extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,19 @@ pub async fn extensions_install_handler(
_ => None,
});

let normalized_name = crate::tools::mcp::config::normalize_server_name(&req.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 Severity — Missing empty-name guard after normalization

normalize_server_name("@#$") returns "". The CLI's add_server checks for this (line 186–188) and returns an error, and the JS frontend checks if (!name) too. But this web handler passes the empty string straight to ext_mgr.install().

A direct API caller (bypassing the JS frontend) could create a server with an empty name.

Suggested fix:

let normalized_name = crate::tools::mcp::config::normalize_server_name(&req.name);
if normalized_name.is_empty() {
    return Ok(Json(ActionResponse::fail(
        "Server name contains no valid characters".to_string(),
    )));
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in bc3f863. Added empty-name guard after normalization in the web API handler. Direct API callers sending names like "@#$" now get a clear error instead of creating a server with an empty name.

if normalized_name.is_empty() {
return Ok(Json(ActionResponse::fail(
"Server name contains no valid characters".to_string(),
)));
}
match ext_mgr
.install(&req.name, req.url.as_deref(), kind_hint, &user.user_id)
.install(
&normalized_name,
req.url.as_deref(),
kind_hint,
&user.user_id,
)
.await
{
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
Expand Down
76 changes: 71 additions & 5 deletions src/cli/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::secrets::SecretsStore;
use crate::tools::mcp::{
McpClient, McpProcessManager, McpServerConfig, McpSessionManager, OAuthConfig,
auth::{authorize_mcp_server, is_authenticated},
config::{self, EffectiveTransport, McpServersFile},
config::{self, EffectiveTransport, McpServersFile, normalize_server_name},
factory::create_client_from_config,
};

Expand Down Expand Up @@ -166,7 +166,7 @@ pub async fn run_mcp_command(cmd: McpCommand) -> anyhow::Result<()> {
/// Add a new MCP server.
async fn add_server(args: McpAddArgs) -> anyhow::Result<()> {
let McpAddArgs {
name,
name: raw_name,
url,
transport,
command,
Expand All @@ -181,6 +181,16 @@ async fn add_server(args: McpAddArgs) -> anyhow::Result<()> {
description,
} = args;

// Normalize the server name: lowercase, replace special chars with
// underscores so descriptive names like "My Twitter Server" work (#2236).
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 Severity — CLI lookup commands don't normalize the name argument

add_server normalizes the name here, but the four other CLI sub-commands — remove_server (line 292), auth_server (line 418), test_server (line 502), and toggle_server (line 625) — all use the raw user-supplied name for servers.get(&name) / servers.remove(&name).

After this PR, ironclaw mcp add "My Server" https://... stores the server as my_server. But ironclaw mcp remove "My Server" would fail with "Server not found" because the stored name is my_server and the lookup is an exact string match.

Suggested fix: Add let name = normalize_server_name(&name); at the top of remove_server, auth_server, test_server, and toggle_server, matching what add_server does here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in bc3f863. All four CLI lookup commands (remove_server, auth_server, test_server, toggle_server) now normalize the name argument before lookup, matching add_server behavior.

let name = normalize_server_name(&raw_name);
if name.is_empty() {
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.

}

let transport_lower = transport.to_lowercase();

let mut config = match transport_lower.as_str() {
Expand Down Expand Up @@ -290,6 +300,7 @@ async fn add_server(args: McpAddArgs) -> anyhow::Result<()> {

/// Remove an MCP server.
async fn remove_server(name: String) -> anyhow::Result<()> {
let name = normalize_server_name(&name);
let (db, owner_id) = connect_db().await;
let mut servers = load_servers(db.as_deref(), &owner_id).await?;
if !servers.remove(&name) {
Expand Down Expand Up @@ -411,6 +422,7 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> {

/// Authenticate with an MCP server.
async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
let name = normalize_server_name(&name);
// Get server config
let (db, owner_id) = connect_db().await;
let servers = load_servers(db.as_deref(), &owner_id).await?;
Expand Down Expand Up @@ -495,6 +507,7 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {

/// Test connection to an MCP server.
async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
let name = normalize_server_name(&name);
// Get server config
let (db, owner_id) = connect_db().await;
let servers = load_servers(db.as_deref(), &owner_id).await?;
Expand Down Expand Up @@ -568,9 +581,11 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
};
println!(" • {}{}", tool.name, approval);
if !tool.description.is_empty() {
// Truncate long descriptions
let desc = if tool.description.len() > 60 {
format!("{}...", &tool.description[..57])
// 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in dcbb7a7. The .truncate(57) in conversation.rs now uses char_indices() to find a safe truncation point, preventing panics on multi-byte UTF-8 characters.

format!("{truncated}...")
} else {
tool.description.clone()
};
Expand Down Expand Up @@ -618,6 +633,7 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {

/// Toggle server enabled/disabled state.
async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
let name = normalize_server_name(&name);
let (db, owner_id) = connect_db().await;
let mut servers = load_servers(db.as_deref(), &owner_id).await?;

Expand Down Expand Up @@ -741,4 +757,54 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid env var format"));
}

/// Regression test for #1947: byte-index slicing panics on multi-byte UTF-8.
///
/// Reproduces the original bug: a description with multi-byte chars where
/// 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.

// Each CJK character is 3 bytes. 20 chars = 60 bytes.
// Byte index 57 falls inside the 20th character (bytes 57..59).
let description = "\u{4e00}".repeat(20); // 20 CJK chars, 60 bytes
assert_eq!(description.len(), 60);
assert_eq!(description.chars().count(), 20);

// Simulate the fixed truncation logic
let desc = if description.chars().count() > 60 {
let truncated: String = description.chars().take(57).collect();
format!("{truncated}...")
} else {
description.clone()
};
// 20 chars <= 60, so no truncation needed
assert_eq!(desc, description);

// Now test with a string that actually exceeds 60 chars
let long_desc = "\u{4e00}".repeat(61); // 61 CJK chars
assert_eq!(long_desc.chars().count(), 61);
let desc = if long_desc.chars().count() > 60 {
let truncated: String = long_desc.chars().take(57).collect();
format!("{truncated}...")
} else {
long_desc.clone()
};
assert_eq!(desc.chars().count(), 60); // 57 chars + "..."
assert!(desc.ends_with("..."));
}

/// Verify ASCII descriptions still truncate correctly.
#[test]
fn test_tool_description_truncation_ascii() {
let description = "a".repeat(80);
let desc = if description.chars().count() > 60 {
let truncated: String = description.chars().take(57).collect();
format!("{truncated}...")
} else {
description.clone()
};
assert_eq!(desc.len(), 60);
assert!(desc.ends_with("..."));
}
}
Loading
Loading