use std::{
sync::Arc,
time::{Duration, Instant},
};
use rmcp::schemars;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
const SKILLS_BASE_DEFAULT: &str = "https://skills.internetcomputer.org";
const CACHE_TTL: Duration = Duration::from_secs(15 * 60);
fn skills_base() -> String {
resolve_skills_base(std::env::var("SKILLS_URL").ok())
}
fn resolve_skills_base(configured: Option<String>) -> String {
configured
.map(|s| s.trim().trim_end_matches('/').to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| SKILLS_BASE_DEFAULT.to_string())
}
fn markdown_url(name: &str, candidate: &str) -> String {
markdown_url_for_base(&skills_base(), name, candidate)
}
fn markdown_url_for_base(base: &str, name: &str, candidate: &str) -> String {
let fallback = format!("{base}/.well-known/skills/{name}/SKILL.md");
let Ok(base_url) = url::Url::parse(base) else {
return fallback;
};
match url::Url::parse(candidate) {
Ok(u)
if u.host_str().is_some()
&& u.host_str() == base_url.host_str()
&& (u.scheme() == "https" || u.scheme() == base_url.scheme()) =>
{
candidate.to_string()
}
_ => fallback,
}
}
#[derive(Deserialize, Clone)]
pub struct SkillEntry {
pub name: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub category: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub urls: SkillUrls,
}
#[derive(Deserialize, Clone, Default)]
pub struct SkillUrls {
#[serde(default)]
pub markdown: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GetSkillArgs {
pub name: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct SkillSummary {
pub name: String,
pub title: String,
pub category: String,
pub description: String,
}
impl From<&SkillEntry> for SkillSummary {
fn from(e: &SkillEntry) -> Self {
Self {
name: e.name.clone(),
title: e.title.clone(),
category: e.category.clone(),
description: e.description.clone(),
}
}
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct SkillsOutput {
pub skills: Vec<SkillSummary>,
}
impl From<Vec<SkillEntry>> for SkillsOutput {
fn from(entries: Vec<SkillEntry>) -> Self {
Self {
skills: entries.iter().map(SkillSummary::from).collect(),
}
}
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct SkillOutput {
pub name: String,
pub content: String,
}
#[derive(Deserialize)]
struct Manifest {
#[serde(default)]
skills: Vec<SkillEntry>,
}
struct Cached {
skills: Vec<SkillEntry>,
fetched_at: Instant,
}
#[derive(Clone, Default)]
pub struct SkillsCatalog {
cache: Arc<RwLock<Option<Cached>>>,
}
impl SkillsCatalog {
pub fn new() -> Self {
Self::default()
}
pub async fn list(&self) -> Result<Vec<SkillEntry>, String> {
if let Some(c) = self.cache.read().await.as_ref() {
if c.fetched_at.elapsed() < CACHE_TTL {
return Ok(c.skills.clone());
}
}
let url = format!("{}/api/skills.json", skills_base());
let client = crate::discover::http_client()?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("could not reach the skills registry: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
"skills registry returned HTTP {}",
resp.status().as_u16()
));
}
let text = resp
.text()
.await
.map_err(|e| format!("reading skills registry: {e}"))?;
let manifest: Manifest =
serde_json::from_str(&text).map_err(|e| format!("could not parse skills manifest: {e}"))?;
let skills = manifest.skills;
*self.cache.write().await = Some(Cached {
skills: skills.clone(),
fetched_at: Instant::now(),
});
Ok(skills)
}
pub async fn get(&self, name: &str) -> Result<String, String> {
let name = name.trim();
let skills = self.list().await?;
let entry = skills.iter().find(|s| s.name.eq_ignore_ascii_case(name));
if entry.is_none() {
return Err(format!(
"no skill named `{name}` — call icp_list_skills to see the available skills"
));
}
let url = markdown_url(name, entry.map(|e| e.urls.markdown.as_str()).unwrap_or(""));
let client = crate::discover::http_client()?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("could not fetch skill `{name}`: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
"fetching skill `{name}` returned HTTP {}",
resp.status().as_u16()
));
}
resp.text()
.await
.map_err(|e| format!("reading skill `{name}`: {e}"))
}
pub fn format_list(skills: &[SkillEntry]) -> String {
use std::collections::BTreeMap;
let mut by_cat: BTreeMap<&str, Vec<&SkillEntry>> = BTreeMap::new();
for s in skills {
let cat = if s.category.trim().is_empty() {
"Other"
} else {
s.category.as_str()
};
by_cat.entry(cat).or_default().push(s);
}
let mut out = String::from(
"Internet Computer skills — authoritative how-to guides. Load one with \
icp_get_skill(name).\n",
);
for (cat, mut items) in by_cat {
items.sort_by(|a, b| a.name.cmp(&b.name));
out.push_str(&format!("\n{cat}:\n"));
for s in items {
out.push_str(&format!("- {} — {}: {}\n", s.name, s.title, s.description));
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_manifest_and_groups_by_category() {
let json = r#"{
"count": 2,
"skills": [
{"name":"motoko","title":"Motoko Language","category":"Motoko",
"description":"Motoko syntax and patterns.",
"urls":{"markdown":"https://x/.well-known/skills/motoko/SKILL.md","html":"https://x/skills/motoko/"}},
{"name":"icp-cli","title":"ICP CLI","category":"Infrastructure",
"description":"Build and deploy with the icp CLI.",
"urls":{"markdown":"https://x/.well-known/skills/icp-cli/SKILL.md"},
"compatibility":null,"updated":"2026-06-17T20:26:42.000Z","license":"Apache-2.0"}
]
}"#;
let manifest: Manifest = serde_json::from_str(json).expect("parse");
assert_eq!(manifest.skills.len(), 2);
let rendered = SkillsCatalog::format_list(&manifest.skills);
assert!(rendered.contains("Motoko:"), "{rendered}");
assert!(rendered.contains("Infrastructure:"), "{rendered}");
assert!(rendered.contains("- motoko — Motoko Language:"), "{rendered}");
assert!(rendered.contains("- icp-cli — ICP CLI:"), "{rendered}");
}
#[test]
fn resolve_skills_base_default_and_override() {
let default = "https://skills.internetcomputer.org";
assert_eq!(resolve_skills_base(None), default);
assert_eq!(resolve_skills_base(Some(String::new())), default);
assert_eq!(resolve_skills_base(Some(" ".into())), default);
assert_eq!(
resolve_skills_base(Some("https://x.example/".into())),
"https://x.example"
);
}
#[test]
fn markdown_url_only_trusts_same_origin() {
let base = "https://skills.internetcomputer.org";
let fallback = "https://skills.internetcomputer.org/.well-known/skills/motoko/SKILL.md";
let good = "https://skills.internetcomputer.org/.well-known/skills/motoko/SKILL.md";
assert_eq!(markdown_url_for_base(base, "motoko", good), good);
assert_eq!(markdown_url_for_base(base, "motoko", "https://evil.example/x"), fallback);
assert_eq!(
markdown_url_for_base(base, "motoko", "http://169.254.169.254/latest/meta-data"),
fallback
);
assert_eq!(markdown_url_for_base(base, "motoko", "file:///etc/passwd"), fallback);
assert_eq!(markdown_url_for_base(base, "motoko", ""), fallback);
assert_eq!(
markdown_url_for_base("http://localhost:8080", "motoko", "http://localhost:8080/x.md"),
"http://localhost:8080/x.md"
);
}
#[tokio::test]
async fn fetches_real_registry_and_a_skill() {
let catalog = SkillsCatalog::new();
let skills = catalog.list().await.expect("list skills");
assert!(!skills.is_empty(), "the registry returned no skills");
let core = ["internet-identity", "icp-cli", "cycles-management"];
let present = skills.iter().find(|s| core.contains(&s.name.as_str())).unwrap_or_else(|| {
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
panic!("none of the core skills {core:?} are in the registry; got {names:?}")
});
let md = catalog.get(&present.name).await.expect("get a core skill's markdown");
assert!(!md.trim().is_empty(), "{}'s SKILL.md was empty", present.name);
}
}