use super::{McpError, McpResult};
use std::{
borrow::Borrow,
cmp::Ordering,
fmt,
hash::{Hash, Hasher},
};
pub(crate) const MCP_QUALIFIED_NAME_MAX_BYTES: usize = 64;
const PREFIX: &str = "mcp__";
const SEPARATOR: &str = "__";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct QualifiedMcpToolName {
server: String,
tool: String,
qualified: String,
}
impl QualifiedMcpToolName {
pub(crate) fn new(server: &str, tool: &str) -> McpResult<Self> {
validate_component("server", server)?;
if server.ends_with('_') {
return Err(McpError::Config(format!(
"MCP server name '{server}' must not end with '_' because mcp__<server>__<tool> would be ambiguous; choose a name that does not end with '_'"
)));
}
validate_component("tool", tool)?;
let qualified = format!("{PREFIX}{server}{SEPARATOR}{tool}");
if qualified.len() > MCP_QUALIFIED_NAME_MAX_BYTES {
return Err(McpError::Config(format!(
"MCP qualified tool name exceeds {MCP_QUALIFIED_NAME_MAX_BYTES} bytes"
)));
}
Ok(Self {
server: server.to_string(),
tool: tool.to_string(),
qualified,
})
}
pub(crate) fn parse(name: &str) -> McpResult<Self> {
let rest = name.strip_prefix(PREFIX).ok_or_else(|| invalid(name))?;
let (server, tool) = rest.split_once(SEPARATOR).ok_or_else(|| invalid(name))?;
if tool.contains(SEPARATOR) {
return Err(invalid(name));
}
let parsed = Self::new(server, tool).map_err(|error| invalid_with_detail(name, error))?;
if parsed.as_str() != name {
return Err(invalid(name));
}
Ok(parsed)
}
pub(crate) fn server(&self) -> &str {
&self.server
}
pub(crate) fn tool(&self) -> &str {
&self.tool
}
pub(crate) fn as_str(&self) -> &str {
&self.qualified
}
}
impl Hash for QualifiedMcpToolName {
fn hash<H: Hasher>(&self, state: &mut H) {
self.as_str().hash(state);
}
}
impl Borrow<str> for QualifiedMcpToolName {
fn borrow(&self) -> &str {
self.as_str()
}
}
impl Ord for QualifiedMcpToolName {
fn cmp(&self, other: &Self) -> Ordering {
self.as_str().cmp(other.as_str())
}
}
impl PartialOrd for QualifiedMcpToolName {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl fmt::Display for QualifiedMcpToolName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
fn validate_component(kind: &str, value: &str) -> McpResult<()> {
if value.is_empty() {
return Err(McpError::Config(format!(
"MCP {kind} name must not be empty"
)));
}
if value.contains(SEPARATOR) {
return Err(McpError::Config(format!(
"MCP {kind} name '{value}' must not contain '__'"
)));
}
if !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err(McpError::Config(format!(
"MCP {kind} name '{value}' must contain only ASCII letters, digits, '_' or '-'"
)));
}
Ok(())
}
fn invalid(name: &str) -> McpError {
McpError::Config(format!(
"invalid MCP qualified tool name '{name}'; expected exactly mcp__<server>__<tool>"
))
}
fn invalid_with_detail(name: &str, error: McpError) -> McpError {
McpError::Config(format!("invalid MCP qualified tool name '{name}': {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
for (server, tool) in [
("server", "search"),
("server", "_search"),
("server-name", "tool_name"),
] {
let n = QualifiedMcpToolName::new(server, tool).unwrap();
let parsed = QualifiedMcpToolName::parse(n.as_str()).unwrap();
assert_eq!(parsed, n);
assert_eq!(parsed.server(), server);
assert_eq!(parsed.tool(), tool);
}
}
#[test]
fn rejects_ambiguous_trailing_server_underscore() {
let tool_with_leading_underscore = QualifiedMcpToolName::new("a", "_tool").unwrap();
assert_eq!(tool_with_leading_underscore.as_str(), "mcp__a___tool");
assert_eq!(
QualifiedMcpToolName::parse(tool_with_leading_underscore.as_str()).unwrap(),
tool_with_leading_underscore
);
let error = QualifiedMcpToolName::new("bad_", "tool")
.unwrap_err()
.to_string();
assert!(error.contains("must not end with '_'"), "{error}");
assert!(error.contains("ambiguous"), "{error}");
}
#[test]
fn rejects_bad_names() {
for n in [
"mcp__server",
"mcp__server__tool__extra",
"mcp__bad/name__tool",
"mcp____tool",
] {
let error = QualifiedMcpToolName::parse(n).unwrap_err().to_string();
assert!(error.contains(n), "{error}");
}
}
#[test]
fn rejects_unicode_components_with_detail() {
let error = QualifiedMcpToolName::parse("mcp__server__é")
.unwrap_err()
.to_string();
assert!(
error.contains("mcp__server__é") && error.contains("ASCII"),
"{error}"
);
}
#[test]
fn accepts_exact_byte_cap_and_rejects_one_over() {
let tool = "x".repeat(64 - "mcp__s__".len());
assert!(QualifiedMcpToolName::new("s", &tool).is_ok());
assert!(QualifiedMcpToolName::new("s", &(tool + "x")).is_err());
}
#[test]
fn ordering_uses_full_qualified_text() {
let mut names = [
QualifiedMcpToolName::new("a-", "tool").unwrap(),
QualifiedMcpToolName::new("a", "tool").unwrap(),
];
names.sort();
assert_eq!(
names.iter().map(|name| name.as_str()).collect::<Vec<_>>(),
vec!["mcp__a-__tool", "mcp__a__tool"]
);
}
}