use serde_json::Value;
pub const DEFAULT_TOOL_TIMEOUT_MS: u64 = 5_000;
#[derive(thiserror::Error, Debug)]
pub enum ToolDescriptorError {
#[error("tool name cannot be empty")]
EmptyName,
#[error(
"tool name must be snake_case (lowercase [a-z0-9_] only, no dots or spaces): got '{0}'"
)]
InvalidName(String),
#[error("tool description cannot be empty")]
EmptyDescription,
#[error("input_schema must be a JSON object ({{...}}), got: {0}")]
SchemaNotObject(String),
}
fn validate_snake_case(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_'))
}
#[derive(Debug, Clone)]
pub struct ToolDescriptor {
pub name: String,
pub description: String,
pub input_schema: Value,
pub timeout_ms: u64,
}
impl ToolDescriptor {
pub fn try_new(
name: impl Into<String>,
description: impl Into<String>,
input_schema: Value,
) -> Result<Self, ToolDescriptorError> {
let name = name.into();
let description = description.into();
if name.is_empty() {
return Err(ToolDescriptorError::EmptyName);
}
if !validate_snake_case(&name) {
return Err(ToolDescriptorError::InvalidName(name));
}
if description.is_empty() {
return Err(ToolDescriptorError::EmptyDescription);
}
if !matches!(input_schema, Value::Object(_)) {
return Err(ToolDescriptorError::SchemaNotObject(
input_schema.to_string(),
));
}
Ok(Self {
name,
description,
input_schema,
timeout_ms: DEFAULT_TOOL_TIMEOUT_MS,
})
}
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
input_schema: Value,
) -> Self {
Self::try_new(name, description, input_schema)
.expect("invalid ToolDescriptor — see ToolDescriptorError variants")
}
pub fn try_with_timeout(
name: impl Into<String>,
description: impl Into<String>,
input_schema: Value,
timeout_ms: u64,
) -> Result<Self, ToolDescriptorError> {
let mut d = Self::try_new(name, description, input_schema)?;
d.timeout_ms = timeout_ms;
Ok(d)
}
pub fn with_timeout(
name: impl Into<String>,
description: impl Into<String>,
input_schema: Value,
timeout_ms: u64,
) -> Self {
Self::try_with_timeout(name, description, input_schema, timeout_ms)
.expect("invalid ToolDescriptor — see ToolDescriptorError variants")
}
pub fn timeout(mut self, ms: u64) -> Self {
self.timeout_ms = ms;
self
}
}
impl From<ToolDescriptor> for rmcp::model::Tool {
fn from(td: ToolDescriptor) -> rmcp::model::Tool {
let input_schema: std::sync::Arc<serde_json::Map<String, Value>> = match td.input_schema {
Value::Object(map) => std::sync::Arc::new(map),
_ => {
tracing::error!(
tool_name = %td.name,
"ToolDescriptor has non-Object input_schema; emitting empty schema to client (subsystem bug)"
);
std::sync::Arc::new(serde_json::Map::new())
}
};
rmcp::model::Tool::new(td.name, td.description, input_schema)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn descriptor_default_timeout_is_5000ms() {
let d = ToolDescriptor::new("test_tool", "test description", json!({"type": "object"}));
assert_eq!(d.timeout_ms, DEFAULT_TOOL_TIMEOUT_MS);
assert_eq!(d.timeout_ms, 5_000);
}
#[test]
fn descriptor_with_timeout_overrides_default() {
let d = ToolDescriptor::with_timeout(
"slow_tool",
"a blocking probe read",
json!({"type": "object"}),
30_000,
);
assert_eq!(d.timeout_ms, 30_000);
}
#[test]
fn descriptor_builder_timeout_setter_works() {
let d = ToolDescriptor::new("snapshot", "device snapshot", json!({"type": "object"}))
.timeout(15_000);
assert_eq!(d.timeout_ms, 15_000);
}
#[test]
fn try_new_sets_default_timeout() {
let d = ToolDescriptor::try_new("get_adc", "read ADC", json!({"type": "object"})).unwrap();
assert_eq!(d.timeout_ms, DEFAULT_TOOL_TIMEOUT_MS);
}
#[test]
fn try_with_timeout_sets_explicit_timeout() {
let d = ToolDescriptor::try_with_timeout(
"get_gpio",
"read GPIO",
json!({"type": "object"}),
1_500,
)
.unwrap();
assert_eq!(d.timeout_ms, 1_500);
}
#[test]
fn try_with_timeout_propagates_validation_errors() {
let err = ToolDescriptor::try_with_timeout(
"",
"empty name should fail",
json!({"type": "object"}),
5_000,
)
.unwrap_err();
assert!(matches!(err, ToolDescriptorError::EmptyName));
}
}