use std::sync::Arc;
use mcp_skill_framework::describe;
use mcp_skill_framework::prelude::*;
use mcp_skill_framework::rmcp::model::RawContent;
use serde::Deserialize;
use serde_json::json;
struct App;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct FormatArgs {
seconds: i64,
#[serde(default)]
style: Option<String>,
}
struct DurationFormat;
impl Skill<App> for DurationFormat {
fn name(&self) -> &'static str {
"duration_format"
}
fn description(&self) -> &'static str {
"Format a duration given in seconds as `human` (default) or `hms` (`HH:MM:SS`)."
}
fn schema(&self) -> Arc<JsonObject> {
schema_for::<FormatArgs>()
}
fn validation_rules(&self) -> &'static [Rule] {
&[Rule::OneOf {
field: "style",
values: &["human", "hms"],
}]
}
fn examples(&self) -> &'static [SkillExample] {
&[
SkillExample {
title: "Human",
args: r#"{"seconds": 9045}"#,
note: Some("Returns `2h 30m 45s`."),
},
SkillExample {
title: "HH:MM:SS",
args: r#"{"seconds": 9045, "style": "hms"}"#,
note: Some("Returns `02:30:45`."),
},
]
}
fn use_cases(&self) -> &'static [&'static str] {
&["Render an interval in a chosen style for display."]
}
fn call<'a>(&self, ctx: SkillCtx<'a, App>) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
Box::pin(async move {
let (_app, a) = ctx.parse::<FormatArgs>()?;
let style = a.style.as_deref().unwrap_or("human");
let secs = a.seconds.abs();
let sign = if a.seconds < 0 { "-" } else { "" };
let formatted = match style {
"hms" => format!(
"{sign}{:02}:{:02}:{:02}",
secs / 3600,
(secs / 60) % 60,
secs % 60
),
_ => {
let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
let mut parts = Vec::new();
if h > 0 {
parts.push(format!("{h}h"));
}
if m > 0 {
parts.push(format!("{m}m"));
}
if s > 0 || parts.is_empty() {
parts.push(format!("{s}s"));
}
format!("{sign}{}", parts.join(" "))
}
};
Ok(text_result(
json!({ "seconds": a.seconds, "style": style, "formatted": formatted }).to_string(),
))
})
}
}
fn result_text(result: &CallToolResult) -> String {
result
.content
.iter()
.find_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.clone()),
_ => None,
})
.unwrap_or_default()
}
#[tokio::main]
async fn main() {
let skill = DurationFormat;
let bad: JsonObject = serde_json::from_str(r#"{"seconds": 60, "style": "whisper"}"#).unwrap();
match skill.validate(&bad) {
ValidationResult::Fail(v) => println!("rejected: {}", v[0].message),
ValidationResult::Pass => println!("(unexpectedly passed)"),
}
let good: JsonObject = serde_json::from_str(r#"{"seconds": 9045, "style": "hms"}"#).unwrap();
let ctx = SkillCtx {
server: &App,
args: good,
peer: None,
meta: None,
};
match skill.call(ctx).await {
Ok(result) => println!("result: {}", result_text(&result)),
Err(e) => println!("error: {}", e.message),
}
println!(
"\n{}",
describe::render_skill(&skill, Some("duration"), None)
);
let _route = route_skill(Box::new(DurationFormat));
println!("route built for `duration_format`");
}