1use std::{collections::BTreeSet, fmt, hash::Hash, str::FromStr};
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
4
5use crate::ToolError;
6
7const MAX_TOOL_NAME_BYTES: usize = 128;
8pub(crate) const TOOL_NAME_NAMESPACE_SEPARATOR: &str = "__";
9
10#[derive(Clone, Debug)]
24pub struct ToolName {
25 inner: ToolNameInner,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
29enum ToolNameInner {
30 Local { rendered: String },
31 Mcp { rendered: String, native: String },
32}
33
34#[derive(Debug)]
36pub(crate) struct McpToolNameContext {
37 namespace: Option<String>,
38 rendered_names: BTreeSet<String>,
39}
40
41impl McpToolNameContext {
42 pub(crate) fn new(namespace: Option<&ToolName>) -> Result<Self, ToolError> {
43 let namespace = namespace.map(|name| name.as_str().to_owned());
44 if let Some(namespace) = &namespace {
45 Self::validate_namespace(namespace)?;
46 }
47 Ok(Self {
48 namespace,
49 rendered_names: BTreeSet::new(),
50 })
51 }
52
53 fn allocate_rendered_name(&mut self, native_name: &str) -> Result<String, ToolError> {
54 let preferred_leaf = Self::sanitize_native_leaf(native_name);
55 if let Some(rendered) = self.try_reserve_leaf(&preferred_leaf)? {
56 return Ok(rendered);
57 }
58
59 let hash = Self::stable_short_hash(native_name);
60 for counter in 0_u32.. {
61 let suffix = if counter == 0 {
62 format!("_{hash}")
63 } else {
64 format!("_{hash}_{counter}")
65 };
66 if let Some(leaf) =
67 Self::truncate_leaf_with_suffix(&preferred_leaf, &suffix, self.leaf_budget())
68 && let Some(rendered) = self.try_reserve_leaf(&leaf)?
69 {
70 return Ok(rendered);
71 }
72 }
73
74 Err(ToolError::InvalidConfiguration {
75 message: "failed to allocate a rendered MCP tool name".to_owned(),
76 })
77 }
78
79 fn leaf_budget(&self) -> usize {
80 match &self.namespace {
81 Some(namespace) => MAX_TOOL_NAME_BYTES
82 .saturating_sub(namespace.len())
83 .saturating_sub(TOOL_NAME_NAMESPACE_SEPARATOR.len()),
84 None => MAX_TOOL_NAME_BYTES,
85 }
86 }
87
88 fn try_reserve_leaf(&mut self, leaf: &str) -> Result<Option<String>, ToolError> {
89 if leaf.is_empty() {
90 return Ok(None);
91 }
92 let rendered = match &self.namespace {
93 Some(namespace) => {
94 format!("{namespace}{TOOL_NAME_NAMESPACE_SEPARATOR}{leaf}")
95 }
96 None => leaf.to_owned(),
97 };
98 ToolName::validate_rendered(&rendered)?;
99 if self.rendered_names.insert(rendered.clone()) {
100 Ok(Some(rendered))
101 } else {
102 Ok(None)
103 }
104 }
105
106 fn validate_namespace(value: &str) -> Result<(), ToolError> {
107 ToolName::validate_rendered(value)?;
108 let minimum_fallback_leaf = "tool_00000000";
109 if value.len() + TOOL_NAME_NAMESPACE_SEPARATOR.len() + minimum_fallback_leaf.len()
110 > MAX_TOOL_NAME_BYTES
111 {
112 return Err(ToolName::invalid(
113 "tool namespaces must leave room for fallback tool names",
114 ));
115 }
116 Ok(())
117 }
118
119 fn sanitize_native_leaf(native_name: &str) -> String {
120 let leaf = native_name
121 .rsplit(['/', ':', '.'])
122 .next()
123 .unwrap_or(native_name)
124 .trim();
125 let mut sanitized = String::with_capacity(leaf.len());
126 let mut previous_separator = false;
127 for byte in leaf.bytes() {
128 let rendered = if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') {
129 previous_separator = false;
130 byte as char
131 } else {
132 if previous_separator {
133 continue;
134 }
135 previous_separator = true;
136 '_'
137 };
138 sanitized.push(rendered);
139 }
140 let sanitized = sanitized.trim_matches('_').to_owned();
141 if sanitized.is_empty() {
142 "tool".to_owned()
143 } else {
144 sanitized
145 }
146 }
147
148 fn stable_short_hash(value: &str) -> String {
149 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
150 for byte in value.as_bytes() {
151 hash ^= u64::from(*byte);
152 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
153 }
154 format!("{:08x}", hash as u32)
155 }
156
157 fn truncate_leaf_with_suffix(leaf: &str, suffix: &str, budget: usize) -> Option<String> {
158 if suffix.len() > budget {
159 return None;
160 }
161 let max_prefix = budget - suffix.len();
162 let mut prefix = String::new();
163 for ch in leaf.chars() {
164 if prefix.len() + ch.len_utf8() > max_prefix {
165 break;
166 }
167 prefix.push(ch);
168 }
169 let prefix = prefix.trim_end_matches(['_', '-']);
170 let prefix = if prefix.is_empty() { "tool" } else { prefix };
171 Some(format!("{prefix}{suffix}"))
172 }
173}
174
175impl ToolName {
176 pub fn new(value: impl Into<String>) -> Result<Self, ToolError> {
195 Self::local(value)
196 }
197
198 pub(crate) fn local(value: impl Into<String>) -> Result<Self, ToolError> {
199 let rendered = value.into();
200 Self::validate_rendered(&rendered)?;
201 Ok(Self {
202 inner: ToolNameInner::Local { rendered },
203 })
204 }
205
206 pub(crate) fn mcp(
207 context: &mut McpToolNameContext,
208 native_name: impl Into<String>,
209 ) -> Result<Self, ToolError> {
210 let native = native_name.into();
211 if native.trim().is_empty() {
212 return Err(ToolError::InvalidConfiguration {
213 message: "MCP tool names must not be empty".to_owned(),
214 });
215 }
216 let rendered = context.allocate_rendered_name(&native)?;
217 Ok(Self {
218 inner: ToolNameInner::Mcp { rendered, native },
219 })
220 }
221
222 #[must_use]
236 pub fn as_str(&self) -> &str {
237 match &self.inner {
238 ToolNameInner::Local { rendered } | ToolNameInner::Mcp { rendered, .. } => rendered,
239 }
240 }
241
242 pub(crate) fn mcp_native_name(&self) -> Option<&str> {
243 match &self.inner {
244 ToolNameInner::Local { .. } => None,
245 ToolNameInner::Mcp { native, .. } => Some(native),
246 }
247 }
248
249 fn validate_rendered(value: &str) -> Result<(), ToolError> {
250 if value.is_empty() {
251 return Err(Self::invalid("tool names must not be empty"));
252 }
253 if value.len() > MAX_TOOL_NAME_BYTES {
254 return Err(Self::invalid("tool names must not exceed 128 bytes"));
255 }
256 if value.trim() != value {
257 return Err(Self::invalid(
258 "tool names must not contain leading or trailing whitespace",
259 ));
260 }
261 if !value
262 .bytes()
263 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
264 {
265 return Err(Self::invalid(
266 "tool names may contain only ASCII letters, digits, `_`, and `-`",
267 ));
268 }
269 Ok(())
270 }
271
272 fn invalid(message: impl Into<String>) -> ToolError {
273 ToolError::InvalidConfiguration {
274 message: message.into(),
275 }
276 }
277}
278
279impl PartialEq for ToolName {
280 fn eq(&self, other: &Self) -> bool {
281 self.as_str() == other.as_str()
282 }
283}
284
285impl Eq for ToolName {}
286
287impl PartialOrd for ToolName {
288 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
289 Some(self.cmp(other))
290 }
291}
292
293impl Ord for ToolName {
294 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
295 self.as_str().cmp(other.as_str())
296 }
297}
298
299impl Hash for ToolName {
300 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
301 self.as_str().hash(state);
302 }
303}
304
305impl fmt::Display for ToolName {
306 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307 f.write_str(self.as_str())
308 }
309}
310
311impl FromStr for ToolName {
312 type Err = ToolError;
313
314 fn from_str(value: &str) -> Result<Self, Self::Err> {
315 Self::local(value)
316 }
317}
318
319impl TryFrom<String> for ToolName {
320 type Error = ToolError;
321
322 fn try_from(value: String) -> Result<Self, Self::Error> {
323 Self::local(value)
324 }
325}
326
327impl TryFrom<&str> for ToolName {
328 type Error = ToolError;
329
330 fn try_from(value: &str) -> Result<Self, Self::Error> {
331 Self::local(value)
332 }
333}
334
335impl Serialize for ToolName {
336 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
337 where
338 S: Serializer,
339 {
340 serializer.serialize_str(self.as_str())
341 }
342}
343
344impl<'de> Deserialize<'de> for ToolName {
345 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
346 where
347 D: Deserializer<'de>,
348 {
349 let value = String::deserialize(deserializer)?;
350 Self::local(value).map_err(D::Error::custom)
351 }
352}