const MAX_TOOL_NAME_LEN: usize = 64;
fn is_provider_safe(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}
pub fn validate_server_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("an MCP server name cannot be empty".to_string());
}
let bad: Vec<String> = name
.chars()
.filter(|c| !is_provider_safe(*c))
.map(|c| format!("{c:?}"))
.collect();
if !bad.is_empty() {
return Err(format!(
"MCP server name {name:?} contains {}, which a model provider will not accept in a \
tool name. The server's tools are named <server>__<tool>, so the name is used as \
you write it. Use letters, digits, `_` or `-`",
bad.join(", ")
));
}
if name.len() >= MAX_TOOL_NAME_LEN {
return Err(format!(
"MCP server name {name:?} is {} characters, which leaves no room for a tool name \
within the {MAX_TOOL_NAME_LEN}-character limit a model provider allows. Use a \
shorter name",
name.len()
));
}
Ok(())
}
pub fn sanitize_tool_name(name: &str) -> String {
let mut out: String = name
.chars()
.map(|c| if is_provider_safe(c) { c } else { '_' })
.collect();
out.truncate(MAX_TOOL_NAME_LEN);
if out.is_empty() {
"tool".to_string()
} else {
out
}
}
pub fn advertised_name(server: &str, tool: &str) -> String {
sanitize_tool_name(&format!("{server}__{tool}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_clean_name_passes_through() {
assert_eq!(sanitize_tool_name("create_issue"), "create_issue");
assert_eq!(sanitize_tool_name("find-all"), "find-all");
}
#[test]
fn dots_and_other_illegal_characters_become_underscores() {
assert_eq!(sanitize_tool_name("my.tools"), "my_tools");
assert_eq!(sanitize_tool_name("weird name!/#"), "weird_name___");
}
#[test]
fn a_name_of_only_illegal_characters_falls_back() {
assert_eq!(sanitize_tool_name("!!!"), "___");
assert_eq!(sanitize_tool_name(""), "tool");
}
#[test]
fn a_long_name_is_truncated_to_the_provider_limit() {
let long = "a".repeat(100);
assert_eq!(sanitize_tool_name(&long).len(), MAX_TOOL_NAME_LEN);
}
#[test]
fn an_advertised_name_joins_with_two_underscores() {
assert_eq!(
advertised_name("tracker", "create_issue"),
"tracker__create_issue"
);
}
#[test]
fn an_advertised_name_sanitizes_both_halves() {
assert_eq!(
advertised_name("my.tools", "find.all"),
"my_tools__find_all"
);
}
#[test]
fn an_advertised_name_is_truncated_after_joining() {
let server = "s".repeat(60);
let name = advertised_name(&server, "create_issue");
assert_eq!(name.len(), MAX_TOOL_NAME_LEN);
assert!(name.starts_with(&server), "the server name is kept whole");
assert!(name.ends_with("__cr"), "the tool name is what gets cut");
}
#[test]
fn an_ordinary_server_name_is_accepted() {
for name in ["tracker", "local-fs", "my_tools", "srv2"] {
assert_eq!(validate_server_name(name), Ok(()), "{name} should be legal");
}
}
#[test]
fn a_dotted_server_name_is_refused_rather_than_rewritten() {
let err = validate_server_name("my.tools").expect_err("a dot is refused");
assert!(err.contains("my.tools"), "{err}");
assert!(
err.contains('.'),
"the message names the offending character: {err}"
);
assert!(err.contains("letters, digits"), "{err}");
}
#[test]
fn every_character_a_provider_refuses_is_named() {
let err = validate_server_name("a b/c").expect_err("refused");
assert!(err.contains(' '), "{err}");
assert!(err.contains('/'), "{err}");
}
#[test]
fn an_empty_server_name_is_refused() {
assert!(validate_server_name("").is_err());
}
#[test]
fn a_server_name_that_fills_the_whole_limit_is_refused() {
let err = validate_server_name(&"s".repeat(MAX_TOOL_NAME_LEN)).expect_err("refused");
assert!(err.contains("shorter name"), "{err}");
assert!(
validate_server_name(&"s".repeat(MAX_TOOL_NAME_LEN - 1)).is_ok(),
"one under the limit is still legal"
);
}
}