use std::collections::BTreeSet;
use lash_core::ToolAgentSurface;
pub fn normalize_identifier(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut last_underscore = false;
for ch in raw.chars() {
let normalized = if ch.is_ascii_alphanumeric() { ch } else { '_' };
if normalized == '_' {
if !last_underscore && !out.is_empty() {
out.push('_');
}
last_underscore = true;
} else {
out.push(normalized.to_ascii_lowercase());
last_underscore = false;
}
}
while out.ends_with('_') {
out.pop();
}
if out.is_empty() {
"tool".to_string()
} else {
out
}
}
pub fn unique_prefixed_name(base: &str, used_names: &mut BTreeSet<String>) -> String {
if used_names.insert(base.to_string()) {
return base.to_string();
}
for idx in 2.. {
let candidate = format!("{base}_{idx}");
if used_names.insert(candidate.clone()) {
return candidate;
}
}
unreachable!("integer range exhausted while uniquifying tool name")
}
pub fn build_prefixed_name(
server_name: &str,
original_tool_name: &str,
used_names: &mut BTreeSet<String>,
) -> (String, ToolAgentSurface) {
let server_prefix = normalize_identifier(server_name);
let normalized_tool = normalize_identifier(original_tool_name);
let prefixed = unique_prefixed_name(
&format!("mcp__{server_prefix}__{normalized_tool}"),
used_names,
);
let agent_surface = ToolAgentSurface::new([server_prefix], normalized_tool)
.with_aliases([original_tool_name.to_string()]);
(prefixed, agent_surface)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_identifier_lowercases_and_dedups_underscores() {
assert_eq!(
normalize_identifier("Spotify-Search Songs"),
"spotify_search_songs"
);
assert_eq!(normalize_identifier("___foo___bar___"), "foo_bar");
assert_eq!(normalize_identifier("!!!"), "tool");
}
#[test]
fn unique_prefixed_name_appends_index_on_collision() {
let mut used = BTreeSet::new();
assert_eq!(unique_prefixed_name("tool", &mut used), "tool");
assert_eq!(unique_prefixed_name("tool", &mut used), "tool_2");
assert_eq!(unique_prefixed_name("tool", &mut used), "tool_3");
}
#[test]
fn build_prefixed_name_keeps_module_path_and_original_alias() {
let mut used = BTreeSet::new();
let (name, meta) = build_prefixed_name("appworld", "spotify-search-songs", &mut used);
assert_eq!(name, "mcp__appworld__spotify_search_songs");
assert_eq!(meta.module_path, vec!["appworld".to_string()]);
assert_eq!(meta.operation.as_deref(), Some("spotify_search_songs"));
assert_eq!(meta.aliases, vec!["spotify-search-songs".to_string()]);
}
}