diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index b0e77c72a..644b9f120 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -72,6 +72,11 @@ base64 = { version = "0.23", optional = true } # SHA-256 for SEP-2640 skill digests and SEP-2322 requestState sealing sha2 = "0.11" +# SKILL.md frontmatter for the `skills-fs` catalog +yaml_serde = { version = "0.10", optional = true } +# reads the `skills-fs` catalog through a root handle that cannot escape it +cap-std = { version = "4", optional = true } + # for SEP-2322 requestState integrity sealing (opt-in via the `request-state` feature) hmac = { version = "0.13", optional = true } zeroize = { version = "1", optional = true } @@ -139,6 +144,7 @@ elicitation = ["dep:url"] # SEP-2322 requestState integrity helper (HMAC-SHA256 seal/open codec) request-state = ["dep:hmac", "dep:zeroize", "base64"] +skills-fs = ["server", "base64", "dep:cap-std", "dep:yaml_serde"] # reqwest http client __reqwest = ["dep:reqwest"] @@ -224,6 +230,7 @@ tracing-subscriber = { version = "0.3", features = [ ] } async-trait = "0.1" rstest = "0.28.0" +tempfile = "3" # Cargo unifies this feature into rmcp whenever any crate in a build enables it, # so the tests run with it on (see tests/test_deserialization.rs). serde_json = { version = "1.0", features = ["arbitrary_precision"] } diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index f34469ef4..6f57acfd5 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -26,6 +26,7 @@ For **getting started**, **usage guides**, and **full MCP feature documentation* | `auth` | OAuth 2.0 authentication support | | | `auth-enterprise-managed` | EMA/XAA refresh-token and ID-JAG exchanges for registered public and confidential clients (includes `auth`) | | | `elicitation` | Elicitation support | | +| `skills-fs` | Serve SEP-2640 skills from a directory tree (`SkillCatalog`) | | ### Transport features diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 94257ee68..c28d66312 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -15,6 +15,8 @@ pub mod common; pub mod prompt; mod resource; pub mod router; +#[cfg(feature = "skills-fs")] +pub mod skill_catalog; pub mod tool; pub mod tool_name_validation; pub mod wrapper; diff --git a/crates/rmcp/src/handler/server/skill_catalog.rs b/crates/rmcp/src/handler/server/skill_catalog.rs new file mode 100644 index 000000000..0201fb26d --- /dev/null +++ b/crates/rmcp/src/handler/server/skill_catalog.rs @@ -0,0 +1,676 @@ +//! Skills served from a directory tree (SEP-2640). +//! +//! Each folder below the root that holds a `SKILL.md` is a skill, served at +//! `skill:///SKILL.md`. A folder of skills can nest other +//! skills: the nested skill is listed on its own, and its files also appear +//! in the manifest of every skill above it. Hidden entries, whose names +//! start with `.`, are neither listed nor served. +//! +//! A server delegates the skills methods and `resources/read` to the catalog: +//! +//! ```rust,no_run +//! use std::sync::Arc; +//! +//! use rmcp::{ +//! ErrorData, RoleServer, ServerHandler, +//! handler::server::skill_catalog::SkillCatalog, +//! model::*, +//! service::RequestContext, +//! }; +//! +//! #[derive(Clone)] +//! struct Server { +//! skills: Arc, +//! } +//! +//! impl ServerHandler for Server { +//! fn get_info(&self) -> ServerConfig { +//! ServerConfig::new( +//! ServerCapabilities::builder() +//! .enable_resources() +//! .enable_skills_with_directory_read() +//! .build(), +//! ) +//! } +//! +//! async fn list_skills( +//! &self, +//! _request: Option, +//! _context: RequestContext, +//! ) -> Result { +//! Ok(self.skills.list_skills()) +//! } +//! +//! async fn get_skill( +//! &self, +//! request: GetSkillRequestParams, +//! _context: RequestContext, +//! ) -> Result { +//! self.skills.get_skill(&request.uri) +//! } +//! +//! async fn read_resource_directory( +//! &self, +//! request: ReadResourceDirectoryRequestParams, +//! _context: RequestContext, +//! ) -> Result { +//! self.skills.read_resource_directory(&request.uri) +//! } +//! +//! async fn list_resources( +//! &self, +//! _request: Option, +//! _context: RequestContext, +//! ) -> Result { +//! Ok(ListResourcesResult::with_all_items(self.skills.resources())) +//! } +//! +//! async fn read_resource( +//! &self, +//! request: ReadResourceRequestParams, +//! _context: RequestContext, +//! ) -> Result { +//! self.skills +//! .read_resource(&request.uri) +//! .map(Into::into) +//! .ok_or_else(|| ErrorData::resource_not_found(request.uri, None)) +//! } +//! } +//! +//! # fn main() -> Result<(), Box> { +//! let server = Server { +//! skills: Arc::new(SkillCatalog::from_dir("./skills")?), +//! }; +//! # let _ = server; +//! # Ok(()) +//! # } +//! ``` + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + io::{self, Read}, + path::{Path, PathBuf}, + sync::{Arc, Mutex, PoisonError, RwLock}, +}; + +use base64::{Engine, prelude::BASE64_STANDARD}; +use cap_std::{ambient_authority, fs::Dir}; + +use crate::{ + error::ErrorData as McpError, + model::{ + DIRECTORY_MIME_TYPE, GetSkillResult, ListSkillsResult, ReadResourceDirectoryResult, + ReadResourceResult, Resource, ResourceContents, SKILL_MD, Skill, SkillFrontmatter, + SkillResource, SkillResources, + }, +}; + +/// Files per skill above which the catalog skips the skill; hosts need not load larger ones. +pub const SKILL_FILE_LIMIT: usize = 512; + +/// Bytes per skill above which the catalog skips the skill; hosts need not load larger ones. +pub const SKILL_BYTE_LIMIT: u64 = 16 * 1024 * 1024; + +const URI_SCHEME: &str = "skill://"; +const MARKDOWN_MIME_TYPE: &str = "text/markdown"; + +/// An in-memory snapshot of the skills below a directory. +/// +/// The snapshot holds every file's bytes, so `resources/read` serves exactly +/// the bytes the manifest describes even if the files change on disk. Call +/// [`reload`](Self::reload) to pick up changes. +/// +/// A skill that contains a symlink or another special file is skipped with a +/// warning. Every listing and read goes through a handle on the root that +/// cannot resolve outside it, so no file outside the root is served, even if +/// the tree changes while it loads. +/// Entries whose name starts with `.` are ignored, and a skill over +/// [`SKILL_FILE_LIMIT`] files or [`SKILL_BYTE_LIMIT`] bytes is skipped. Skipped +/// skills are reported through `tracing`. +/// +/// Loading reads the whole tree with blocking I/O; from async code, call it +/// inside `tokio::task::spawn_blocking`. +#[derive(Debug)] +pub struct SkillCatalog { + root: PathBuf, + snapshot: RwLock>, + /// Held for a whole reload, so an older load never replaces a newer one. + reloading: Mutex<()>, +} + +/// A failure that stops a catalog from loading. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SkillCatalogError { + #[error("cannot read {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, +} + +impl SkillCatalog { + /// Load every skill below `root`. + pub fn from_dir(root: impl Into) -> Result { + let root = root.into(); + let snapshot = Snapshot::load(&root)?; + Ok(Self { + root, + snapshot: RwLock::new(Arc::new(snapshot)), + reloading: Mutex::new(()), + }) + } + + /// Load the tree again and swap the new snapshot in. + /// + /// On failure the previous snapshot keeps serving. Returns `true` when any + /// skill entry changed, so the caller can send `resources/list_changed`. + pub fn reload(&self) -> Result { + let _reloading = self + .reloading + .lock() + .unwrap_or_else(PoisonError::into_inner); + let snapshot = Arc::new(Snapshot::load(&self.root)?); + let mut current = self + .snapshot + .write() + .unwrap_or_else(PoisonError::into_inner); + let changed = current.skills != snapshot.skills; + *current = snapshot; + Ok(changed) + } + + /// Every skill, ordered by URI. + pub fn skills(&self) -> Vec { + self.current().skills.clone() + } + + /// The answer to `skills/list`: every skill on one page. + pub fn list_skills(&self) -> ListSkillsResult { + ListSkillsResult::with_all_items(self.skills()) + } + + /// The answer to `skills/get`; `-32602` when no skill has that URI. + pub fn get_skill(&self, uri: &str) -> Result { + self.current() + .skills + .iter() + .find(|skill| skill.uri == uri) + .map(|skill| GetSkillResult::new(skill.clone())) + .ok_or_else(|| McpError::invalid_params(format!("unknown skill {uri}"), None)) + } + + /// The `SKILL.md` of every skill, for `resources/list`. + pub fn resources(&self) -> Vec { + self.current().listed.clone() + } + + /// The answer to `resources/read` for a skill file, or `None` when the + /// catalog does not serve `uri`, so the caller can try its own resources. + pub fn read_resource(&self, uri: &str) -> Option { + let snapshot = self.current(); + let file = snapshot.files.get(uri)?; + let text = file + .mime_type + .is_some_and(is_textual) + .then(|| std::str::from_utf8(&file.bytes).ok()) + .flatten(); + let contents = match text { + Some(text) => ResourceContents::text(text, uri), + None => ResourceContents::blob(BASE64_STANDARD.encode(&file.bytes), uri), + }; + let contents = match file.mime_type { + Some(mime_type) => contents.with_mime_type(mime_type), + None => contents, + }; + Some(ReadResourceResult::new(vec![contents])) + } + + /// The answer to `resources/directory/read`; `-32602` when `uri` is not a + /// directory inside a skill. + pub fn read_resource_directory( + &self, + uri: &str, + ) -> Result { + self.current() + .directories + .get(uri) + .map(|children| ReadResourceDirectoryResult::with_all_items(children.clone())) + .ok_or_else(|| McpError::invalid_params(format!("not a directory: {uri}"), None)) + } + + fn current(&self) -> Arc { + self.snapshot + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } +} + +#[derive(Debug, Default)] +struct Snapshot { + skills: Vec, + /// The `SKILL.md` resource of each skill, for `resources/list`. + listed: Vec, + files: HashMap, + directories: HashMap>, +} + +#[derive(Debug)] +struct CatalogFile { + bytes: Arc<[u8]>, + mime_type: Option<&'static str>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + File, + Directory, + /// A symlink, another special file, or a name that is not UTF-8. + Unsupported, +} + +#[derive(Debug)] +struct Entry { + /// Relative to the root, `/`-separated. + path: String, + kind: Kind, + /// Bytes on disk when walked; `0` for anything but a file. + size: u64, +} + +impl Snapshot { + fn load(root: &Path) -> Result { + let handle = Dir::open_ambient_dir(root, ambient_authority()).map_err(|source| { + SkillCatalogError::Io { + path: root.to_path_buf(), + source, + } + })?; + let tree = walk(root, &handle)?; + let skill_dirs: BTreeSet<&str> = tree + .iter() + .filter(|entry| is_manifest(entry)) + .map(|entry| parent(&entry.path)) + .collect(); + + let mut inside: BTreeMap<&str, Vec<&Entry>> = BTreeMap::new(); + for entry in &tree { + let mut in_skill = false; + let mut dir = parent(&entry.path); + while !dir.is_empty() { + if skill_dirs.contains(dir) { + in_skill = true; + inside.entry(dir).or_default().push(entry); + } + dir = parent(dir); + } + if !in_skill && entry.kind == Kind::Unsupported && !is_manifest(entry) { + tracing::warn!(path = %entry.path, "skipping a symlink or special file"); + } + } + + // Skip what the walk already rules out before reading any bytes. + let mut kept: Vec<(&str, &[&Entry])> = Vec::new(); + for dir in skill_dirs { + if dir.is_empty() { + tracing::warn!("skipping the SKILL.md at the catalog root; a skill needs a folder"); + continue; + } + let entries = inside.get(dir).map(Vec::as_slice).unwrap_or_default(); + match check_walked(entries) { + Ok(()) => kept.push((dir, entries)), + Err(reason) => tracing::warn!(skill = %dir, "skipping skill: {reason}"), + } + } + + let mut bytes_of: HashMap<&str, Arc<[u8]>> = HashMap::new(); + let mut within_limit: Vec<(&str, &[&Entry])> = Vec::new(); + for (dir, entries) in kept { + if read_within_limit(&handle, root, entries, &mut bytes_of)? { + within_limit.push((dir, entries)); + } else { + tracing::warn!( + skill = %dir, + "skipping skill: grew past {SKILL_BYTE_LIMIT} bytes after the walk" + ); + } + } + + let mut snapshot = Snapshot::default(); + let mut directories: BTreeSet<&str> = BTreeSet::new(); + for (dir, entries) in within_limit { + let (skill, listing) = match build_skill(dir, entries, &bytes_of) { + Ok(built) => built, + Err(reason) => { + tracing::warn!(skill = %dir, "skipping skill: {reason}"); + continue; + } + }; + directories.insert(dir); + for entry in entries { + match entry.kind { + Kind::Directory => { + directories.insert(&entry.path); + } + Kind::File => { + snapshot + .files + .entry(uri_of(&entry.path)) + .or_insert_with(|| CatalogFile { + bytes: Arc::clone(&bytes_of[entry.path.as_str()]), + mime_type: mime_type_of(&entry.path), + }); + } + Kind::Unsupported => {} + } + } + snapshot.skills.push(skill); + snapshot.listed.push(listing); + } + snapshot.skills.sort_by(|a, b| a.uri.cmp(&b.uri)); + snapshot.listed.sort_by(|a, b| a.uri.cmp(&b.uri)); + + let mut children: HashMap<&str, Vec<&Entry>> = HashMap::new(); + for entry in &tree { + children.entry(parent(&entry.path)).or_default().push(entry); + } + for dir in directories { + let listing = children + .get(dir) + .into_iter() + .flatten() + .filter_map(|entry| directory_child(entry, &snapshot.files)) + .collect(); + snapshot.directories.insert(uri_of(dir), listing); + } + Ok(snapshot) + } +} + +fn directory_child(entry: &Entry, files: &HashMap) -> Option { + let uri = uri_of(&entry.path); + let name = file_name(&entry.path); + match entry.kind { + Kind::Directory => Some(Resource::new(uri, name).with_mime_type(DIRECTORY_MIME_TYPE)), + Kind::File => { + let file = files.get(&uri)?; + let mut resource = Resource::new(uri, name).with_size(file.bytes.len() as u64); + resource.mime_type = file.mime_type.map(Into::into); + Some(resource) + } + Kind::Unsupported => None, + } +} + +/// Rules that the walk alone can check: no special files, and within the limits. +fn check_walked(entries: &[&Entry]) -> Result<(), String> { + if let Some(entry) = entries.iter().find(|entry| entry.kind == Kind::Unsupported) { + return Err(format!( + "{} is a symlink, a special file, or not UTF-8; copy the file instead", + entry.path + )); + } + let files = entries.iter().filter(|entry| entry.kind == Kind::File); + let (files, bytes) = ( + files.clone().count(), + files.map(|entry| entry.size).sum::(), + ); + if files > SKILL_FILE_LIMIT || bytes > SKILL_BYTE_LIMIT { + return Err(format!( + "{files} files and {bytes} bytes exceed the limit of {SKILL_FILE_LIMIT} files or \ + {SKILL_BYTE_LIMIT} bytes, so hosts need not load it" + )); + } + Ok(()) +} + +/// Read the files of one skill into `bytes_of` under one running budget, so a +/// file that grew after the walk cannot exhaust memory. `false` when the skill +/// passed the limit; a truncated read is never cached. +fn read_within_limit<'a>( + handle: &Dir, + root: &Path, + entries: &[&'a Entry], + bytes_of: &mut HashMap<&'a str, Arc<[u8]>>, +) -> Result { + let mut budget = SKILL_BYTE_LIMIT + 1; + for entry in entries.iter().filter(|entry| entry.kind == Kind::File) { + let len = match bytes_of.get(entry.path.as_str()) { + Some(bytes) => bytes.len() as u64, + None => { + let mut bytes = Vec::new(); + handle + .open(&entry.path) + .and_then(|file| file.take(budget).read_to_end(&mut bytes)) + .map_err(|source| SkillCatalogError::Io { + path: root.join(&entry.path), + source, + })?; + let len = bytes.len() as u64; + if len < budget { + bytes_of.insert(&entry.path, bytes.into()); + } + len + } + }; + budget = budget.saturating_sub(len); + if budget == 0 { + return Ok(false); + } + } + Ok(true) +} + +fn build_skill( + dir: &str, + entries: &[&Entry], + bytes_of: &HashMap<&str, Arc<[u8]>>, +) -> Result<(Skill, Resource), String> { + let manifest_path = format!("{dir}/{SKILL_MD}"); + let manifest = bytes_of + .get(manifest_path.as_str()) + .ok_or_else(|| format!("{manifest_path} is not a regular file"))?; + let frontmatter = parse_frontmatter(manifest)?; + + let mut files: Vec = entries + .iter() + .filter(|entry| entry.kind == Kind::File) + .map(|entry| SkillResource::from_bytes(uri_of(&entry.path), &bytes_of[entry.path.as_str()])) + .collect(); + files.sort_by(|a, b| a.uri.cmp(&b.uri)); + + let uri = uri_of(&manifest_path); + let listing = Resource::new(&uri, &frontmatter.name) + .with_description(&frontmatter.description) + .with_mime_type(MARKDOWN_MIME_TYPE) + .with_size(manifest.len() as u64); + let skill = Skill::new(uri, frontmatter, SkillResources::Files(files)); + skill.validate().map_err(|error| error.to_string())?; + Ok((skill, listing)) +} + +/// Parse the YAML block between the leading `---` lines of a `SKILL.md`. +fn parse_frontmatter(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| "SKILL.md is not UTF-8".to_string())?; + let mut lines = text.split_inclusive('\n'); + if lines.next().map(str::trim_end) != Some("---") { + return Err("SKILL.md does not start with a --- frontmatter block".into()); + } + let start = text.find('\n').map_or(text.len(), |index| index + 1); + let mut end = start; + for line in lines { + if line.trim_end() == "---" { + return yaml_serde::from_str(&text[start..end]) + .map_err(|error| format!("invalid SKILL.md frontmatter: {error}")); + } + end += line.len(); + } + Err("SKILL.md frontmatter has no closing ---".into()) +} + +/// Every entry below `root` except hidden ones, depth first, by name. +fn walk(root: &Path, handle: &Dir) -> Result, SkillCatalogError> { + fn visit( + root: &Path, + handle: &Dir, + dir: &str, + tree: &mut Vec, + ) -> Result<(), SkillCatalogError> { + let io_error = |source| SkillCatalogError::Io { + path: root.join(dir), + source, + }; + let mut entries = handle + .read_dir(if dir.is_empty() { "." } else { dir }) + .map_err(io_error)? + .collect::, _>>() + .map_err(io_error)?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let name = entry.file_name(); + let (name, utf8) = match name.into_string() { + Ok(name) => (name, true), + Err(name) => (name.to_string_lossy().into_owned(), false), + }; + if name.starts_with('.') { + continue; + } + // `DirEntry::file_type` does not follow symlinks. + let file_type = entry.file_type().map_err(io_error)?; + let kind = match (utf8, file_type.is_file(), file_type.is_dir()) { + (true, true, _) => Kind::File, + (true, _, true) => Kind::Directory, + _ => Kind::Unsupported, + }; + let path = if dir.is_empty() { + name + } else { + format!("{dir}/{name}") + }; + if kind == Kind::Directory { + tree.push(Entry { + path: path.clone(), + kind, + size: 0, + }); + visit(root, handle, &path, tree)?; + } else { + let size = match kind { + Kind::File => entry.metadata().map_err(io_error)?.len(), + _ => 0, + }; + tree.push(Entry { path, kind, size }); + } + } + Ok(()) + } + + let mut tree = Vec::new(); + visit(root, handle, "", &mut tree)?; + Ok(tree) +} + +/// Any non-directory named `SKILL.md`, so a symlinked one is reported, not ignored. +fn is_manifest(entry: &Entry) -> bool { + entry.kind != Kind::Directory && file_name(&entry.path) == SKILL_MD +} + +fn parent(path: &str) -> &str { + path.rsplit_once('/').map_or("", |(parent, _)| parent) +} + +fn file_name(path: &str) -> &str { + path.rsplit_once('/').map_or(path, |(_, name)| name) +} + +fn uri_of(path: &str) -> String { + let mut uri = String::from(URI_SCHEME); + for byte in path.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') { + uri.push(byte as char); + } else { + uri.push_str(&format!("%{byte:02X}")); + } + } + uri +} + +/// Whether files of this type are sent as `text` rather than a base64 `blob`. +fn is_textual(mime_type: &str) -> bool { + mime_type.starts_with("text/") + || matches!( + mime_type, + "application/json" + | "application/xml" + | "application/yaml" + | "application/toml" + | "application/x-sh" + | "image/svg+xml" + ) +} + +/// `None` for an unknown extension, so `mimeType` is omitted. +fn mime_type_of(path: &str) -> Option<&'static str> { + let extension = file_name(path) + .rsplit_once('.') + .map(|(_, extension)| extension.to_ascii_lowercase()); + let mime_type = match extension.as_deref() { + Some("md" | "markdown") => MARKDOWN_MIME_TYPE, + Some("txt") => "text/plain", + Some("html" | "htm") => "text/html", + Some("css") => "text/css", + Some("csv") => "text/csv", + Some("xml") => "application/xml", + Some("json") => "application/json", + Some("yaml" | "yml") => "application/yaml", + Some("toml") => "application/toml", + Some("js" | "mjs") => "text/javascript", + Some("ts") => "text/x-typescript", + Some("py") => "text/x-python", + Some("rs") => "text/x-rust", + Some("sh") => "application/x-sh", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("svg") => "image/svg+xml", + Some("webp") => "image/webp", + Some("pdf") => "application/pdf", + Some("zip") => "application/zip", + _ => return None, + }; + Some(mime_type) +} + +#[cfg(test)] +mod tests { + use std::fs::File; + + use super::*; + + #[test] + fn read_within_limit_should_stop_at_the_budget_and_not_cache_the_truncated_file() { + let dir = tempfile::tempdir().unwrap(); + for name in ["a", "b"] { + File::create(dir.path().join(name)) + .unwrap() + .set_len(SKILL_BYTE_LIMIT) + .unwrap(); + } + // Sizes the walk recorded before the files grew. + let stale = |name: &str| Entry { + path: name.to_string(), + kind: Kind::File, + size: 1, + }; + let (a, b) = (stale("a"), stale("b")); + let handle = Dir::open_ambient_dir(dir.path(), ambient_authority()).unwrap(); + let mut bytes_of = HashMap::new(); + + let within = read_within_limit(&handle, dir.path(), &[&a, &b], &mut bytes_of).unwrap(); + + assert!(!within); + assert_eq!(bytes_of.keys().collect::>(), vec![&"a"]); + } +} diff --git a/crates/rmcp/tests/test_skill_catalog.rs b/crates/rmcp/tests/test_skill_catalog.rs new file mode 100644 index 000000000..b4c4f8423 --- /dev/null +++ b/crates/rmcp/tests/test_skill_catalog.rs @@ -0,0 +1,654 @@ +//! `SkillCatalog` over a real directory tree, and served through `Peer`. +#![cfg(not(feature = "local"))] +#![cfg(all(feature = "client", feature = "skills-fs"))] + +use std::{fs, path::Path, sync::Arc}; + +use base64::{Engine, prelude::BASE64_STANDARD}; +use rmcp::{ + ClientHandler, ErrorData, RoleClient, RoleServer, ServerHandler, + handler::server::skill_catalog::{SKILL_BYTE_LIMIT, SKILL_FILE_LIMIT, SkillCatalog}, + model::{ + ClientConfig, DIRECTORY_MIME_TYPE, ErrorCode, GetSkillRequestParams, GetSkillResult, + InitializeResult, ListResourcesResult, ListSkillsResult, PaginatedRequestParams, + ProtocolVersion, ReadResourceDirectoryRequestParams, ReadResourceDirectoryResult, + ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, ResourceContents, + ServerCapabilities, ServerConfig, Skill, SkillResources, + }, + service::{RequestContext, RunningService, serve_directly}, +}; +use tempfile::TempDir; +use tokio::task::JoinHandle; + +const PDF_SKILL_MD: &str = "---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\nmetadata:\n version: \"1.2\"\n---\n\n# PDF processing\n"; +const BILLING_SKILL_MD: &str = "---\nname: billing\ndescription: Billing workflows\n---\n"; +const REFUNDS_SKILL_MD: &str = "---\nname: refunds\ndescription: Refund workflows\n---\n"; + +const PDF_SKILL_URI: &str = "skill://pdf-processing/SKILL.md"; +const INVOICE_URI: &str = "skill://pdf-processing/templates/invoice.md"; +const TEMPLATES_DIR_URI: &str = "skill://pdf-processing/templates"; +const BILLING_SKILL_URI: &str = "skill://acme/billing/SKILL.md"; +const REFUNDS_SKILL_URI: &str = "skill://acme/billing/refunds/SKILL.md"; +const REFUND_EMAIL_URI: &str = "skill://acme/billing/refunds/examples/email.md"; + +fn write(root: &Path, path: &str, contents: impl AsRef<[u8]>) { + let path = root.join(path); + fs::create_dir_all(path.parent().expect("parent")).expect("create dirs"); + fs::write(path, contents).expect("write file"); +} + +/// `pdf-processing` with a template folder, and `acme/billing` holding a +/// nested `refunds` skill. +fn skills_tree() -> TempDir { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + write(root, "pdf-processing/SKILL.md", PDF_SKILL_MD); + write(root, "pdf-processing/templates/invoice.md", "# Invoice\n"); + write(root, "acme/billing/SKILL.md", BILLING_SKILL_MD); + write(root, "acme/billing/refunds/SKILL.md", REFUNDS_SKILL_MD); + write( + root, + "acme/billing/refunds/examples/email.md", + "Dear customer\n", + ); + dir +} + +fn load(dir: &TempDir) -> SkillCatalog { + SkillCatalog::from_dir(dir.path()).expect("catalog loads") +} + +fn uris(skills: &[Skill]) -> Vec<&str> { + skills.iter().map(|skill| skill.uri.as_str()).collect() +} + +fn manifest_uris(skill: &Skill) -> Vec<&str> { + match &skill.resources { + SkillResources::Files(files) => files.iter().map(|file| file.uri.as_str()).collect(), + other => panic!("expected a file manifest, got {other:?}"), + } +} + +fn skill<'a>(skills: &'a [Skill], uri: &str) -> &'a Skill { + skills + .iter() + .find(|skill| skill.uri == uri) + .unwrap_or_else(|| panic!("no skill {uri}")) +} + +fn read_bytes(catalog: &SkillCatalog, uri: &str) -> Vec { + let result = catalog.read_resource(uri).expect("served"); + match &result.contents[0] { + ResourceContents::TextResourceContents { text, .. } => text.as_bytes().to_vec(), + ResourceContents::BlobResourceContents { blob, .. } => { + BASE64_STANDARD.decode(blob).expect("base64") + } + other => panic!("unexpected contents {other:?}"), + } +} + +mod loading { + use super::*; + + #[test] + fn should_list_every_skill_ordered_by_uri_including_nested_ones() { + let dir = skills_tree(); + + let skills = load(&dir).skills(); + + assert_eq!( + uris(&skills), + [BILLING_SKILL_URI, REFUNDS_SKILL_URI, PDF_SKILL_URI] + ); + } + + #[test] + fn should_list_nested_skill_files_in_the_parent_manifest() { + let dir = skills_tree(); + + let skills = load(&dir).skills(); + + assert_eq!( + manifest_uris(skill(&skills, BILLING_SKILL_URI)), + [BILLING_SKILL_URI, REFUNDS_SKILL_URI, REFUND_EMAIL_URI] + ); + } + + #[test] + fn should_keep_extra_frontmatter_fields() { + let dir = skills_tree(); + + let skills = load(&dir).skills(); + + assert_eq!( + skill(&skills, PDF_SKILL_URI).frontmatter.extra["metadata"], + serde_json::json!({ "version": "1.2" }) + ); + } + + #[test] + fn should_ignore_hidden_entries() { + let dir = skills_tree(); + write(dir.path(), "pdf-processing/.DS_Store", "junk"); + + let skills = load(&dir).skills(); + + assert_eq!( + manifest_uris(skill(&skills, PDF_SKILL_URI)), + [PDF_SKILL_URI, INVOICE_URI] + ); + } + + #[test] + fn should_skip_a_skill_whose_name_does_not_match_its_folder() { + let dir = skills_tree(); + write( + dir.path(), + "renamed/SKILL.md", + "---\nname: original\ndescription: Moved folder\n---\n", + ); + + let skills = load(&dir).skills(); + + assert_eq!( + uris(&skills), + [BILLING_SKILL_URI, REFUNDS_SKILL_URI, PDF_SKILL_URI] + ); + } + + #[test] + fn should_skip_a_skill_without_frontmatter() { + let dir = skills_tree(); + write(dir.path(), "plain/SKILL.md", "# No frontmatter\n"); + + let skills = load(&dir).skills(); + + assert!(!uris(&skills).contains(&"skill://plain/SKILL.md")); + } + + #[test] + fn should_skip_a_skill_over_the_file_limit() { + let dir = skills_tree(); + for index in 0..SKILL_FILE_LIMIT { + write( + dir.path(), + &format!("pdf-processing/pages/{index}.md"), + "page\n", + ); + } + + let skills = load(&dir).skills(); + + assert!(!uris(&skills).contains(&PDF_SKILL_URI)); + } + + #[test] + fn should_skip_a_skill_over_the_byte_limit() { + let dir = skills_tree(); + fs::File::create(dir.path().join("pdf-processing/large.bin")) + .and_then(|file| file.set_len(SKILL_BYTE_LIMIT + 1)) + .expect("sparse file"); + + let skills = load(&dir).skills(); + + assert!(!uris(&skills).contains(&PDF_SKILL_URI)); + } + + #[test] + fn should_skip_a_skill_at_the_root() { + let dir = TempDir::new().expect("temp dir"); + write( + dir.path(), + "SKILL.md", + "---\nname: root\ndescription: At the root\n---\n", + ); + + let skills = load(&dir).skills(); + + assert!(skills.is_empty()); + } + + #[cfg(unix)] + #[test] + fn should_skip_a_skill_that_contains_a_symlink() { + let dir = skills_tree(); + let outside = TempDir::new().expect("temp dir"); + write(outside.path(), "secret.txt", "do not serve"); + std::os::unix::fs::symlink( + outside.path().join("secret.txt"), + dir.path().join("pdf-processing/templates/secret.txt"), + ) + .expect("symlink"); + + let catalog = load(&dir); + + assert!(!uris(&catalog.skills()).contains(&PDF_SKILL_URI)); + } + + #[cfg(unix)] + #[test] + fn should_not_serve_files_through_a_symlinked_folder() { + let dir = skills_tree(); + let outside = TempDir::new().expect("temp dir"); + write(outside.path(), "secret.txt", "do not serve"); + std::os::unix::fs::symlink(outside.path(), dir.path().join("pdf-processing/linked")) + .expect("symlink"); + + let catalog = load(&dir); + + assert!( + catalog + .read_resource("skill://pdf-processing/linked/secret.txt") + .is_none() + ); + } + + #[test] + fn should_percent_encode_names_outside_the_uri_safe_set() { + let dir = skills_tree(); + write(dir.path(), "pdf-processing/my notes.md", "notes\n"); + + let skills = load(&dir).skills(); + + assert!( + manifest_uris(skill(&skills, PDF_SKILL_URI)) + .contains(&"skill://pdf-processing/my%20notes.md") + ); + } + + #[test] + fn should_fail_when_the_root_is_missing() { + let dir = TempDir::new().expect("temp dir"); + + let result = SkillCatalog::from_dir(dir.path().join("missing")); + + assert!(result.is_err()); + } +} + +mod serving { + use super::*; + + #[test] + fn should_serve_bytes_that_match_every_manifest_entry() { + let dir = skills_tree(); + let catalog = load(&dir); + + for skill in catalog.skills() { + let SkillResources::Files(files) = &skill.resources else { + panic!("expected a file manifest"); + }; + for file in files { + file.verify(&read_bytes(&catalog, &file.uri)) + .unwrap_or_else(|error| panic!("{}: {error}", file.uri)); + } + } + } + + #[test] + fn should_serve_the_loaded_bytes_after_the_file_changes_on_disk() { + let dir = skills_tree(); + let catalog = load(&dir); + write( + dir.path(), + "pdf-processing/templates/invoice.md", + "changed\n", + ); + + let bytes = read_bytes(&catalog, INVOICE_URI); + + assert_eq!(bytes, b"# Invoice\n"); + } + + #[test] + fn should_serve_a_binary_type_as_a_blob_even_when_it_is_valid_utf8() { + let dir = skills_tree(); + write(dir.path(), "pdf-processing/logo.png", "PNG"); + let catalog = load(&dir); + + let result = catalog + .read_resource("skill://pdf-processing/logo.png") + .expect("served"); + + assert!(matches!( + &result.contents[0], + ResourceContents::BlobResourceContents { .. } + )); + } + + #[test] + fn should_serve_non_utf8_files_as_blobs() { + let dir = skills_tree(); + write(dir.path(), "pdf-processing/logo.png", [0x89, b'P', 0xff]); + let catalog = load(&dir); + + let result = catalog + .read_resource("skill://pdf-processing/logo.png") + .expect("served"); + + assert!(matches!( + &result.contents[0], + ResourceContents::BlobResourceContents { mime_type: Some(mime), .. } if mime == "image/png" + )); + } + + #[test] + fn should_omit_the_mime_type_of_an_unknown_extension() { + let dir = skills_tree(); + write(dir.path(), "pdf-processing/data.bin", "DATA"); + let catalog = load(&dir); + + let result = catalog + .read_resource("skill://pdf-processing/data.bin") + .expect("served"); + + assert!(matches!( + &result.contents[0], + ResourceContents::BlobResourceContents { + mime_type: None, + .. + } + )); + } + + #[test] + fn should_return_none_for_a_uri_it_does_not_serve() { + let dir = skills_tree(); + + let result = load(&dir).read_resource("skill://pdf-processing/missing.md"); + + assert!(result.is_none()); + } + + #[test] + fn should_reject_an_unknown_skill_with_invalid_params() { + let dir = skills_tree(); + + let error = load(&dir) + .get_skill("skill://unknown/SKILL.md") + .expect_err("unknown skill"); + + assert_eq!(error.code, ErrorCode::INVALID_PARAMS); + } + + #[test] + fn should_list_directory_children_with_folders_marked() { + let dir = skills_tree(); + write( + dir.path(), + "pdf-processing/templates/regional/eu.md", + "# EU\n", + ); + + let children = load(&dir) + .read_resource_directory(TEMPLATES_DIR_URI) + .expect("directory") + .resources; + + let listed: Vec<(&str, Option<&str>)> = children + .iter() + .map(|child| (child.uri.as_str(), child.mime_type.as_deref())) + .collect(); + assert_eq!( + listed, + [ + (INVOICE_URI, Some("text/markdown")), + ( + "skill://pdf-processing/templates/regional", + Some(DIRECTORY_MIME_TYPE) + ), + ] + ); + } + + #[test] + fn should_reject_a_directory_read_of_a_file() { + let dir = skills_tree(); + + let error = load(&dir) + .read_resource_directory(INVOICE_URI) + .expect_err("a file"); + + assert_eq!(error.code, ErrorCode::INVALID_PARAMS); + } + + #[test] + fn should_list_one_skill_md_resource_per_skill() { + let dir = skills_tree(); + + let resources = load(&dir).resources(); + + let listed: Vec<&str> = resources + .iter() + .map(|resource| resource.uri.as_str()) + .collect(); + assert_eq!( + listed, + [BILLING_SKILL_URI, REFUNDS_SKILL_URI, PDF_SKILL_URI] + ); + } +} + +mod reloading { + use super::*; + + #[test] + fn should_pick_up_a_new_skill() { + let dir = skills_tree(); + let catalog = load(&dir); + write( + dir.path(), + "notes/SKILL.md", + "---\nname: notes\ndescription: Notes\n---\n", + ); + + catalog.reload().expect("reload"); + + assert!(uris(&catalog.skills()).contains(&"skill://notes/SKILL.md")); + } + + #[test] + fn should_report_a_change() { + let dir = skills_tree(); + let catalog = load(&dir); + write( + dir.path(), + "pdf-processing/templates/invoice.md", + "changed\n", + ); + + let changed = catalog.reload().expect("reload"); + + assert!(changed); + } + + #[test] + fn should_report_no_change_for_an_identical_tree() { + let dir = skills_tree(); + let catalog = load(&dir); + + let changed = catalog.reload().expect("reload"); + + assert!(!changed); + } + + #[test] + fn should_keep_serving_the_old_snapshot_when_reload_fails() { + let dir = skills_tree(); + let catalog = load(&dir); + fs::remove_dir_all(dir.path()).expect("remove tree"); + catalog.reload().expect_err("root is gone"); + + let skills = catalog.skills(); + + assert_eq!( + uris(&skills), + [BILLING_SKILL_URI, REFUNDS_SKILL_URI, PDF_SKILL_URI] + ); + } +} + +/// Serves a catalog the way an application would wire it up. +#[derive(Debug, Clone)] +struct CatalogServer { + catalog: Arc, +} + +impl ServerHandler for CatalogServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new( + ServerCapabilities::builder() + .enable_resources() + .enable_skills_with_directory_read() + .build(), + ) + } + + async fn list_skills( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(self.catalog.list_skills()) + } + + async fn get_skill( + &self, + request: GetSkillRequestParams, + _context: RequestContext, + ) -> Result { + self.catalog.get_skill(&request.uri) + } + + async fn read_resource_directory( + &self, + request: ReadResourceDirectoryRequestParams, + _context: RequestContext, + ) -> Result { + self.catalog.read_resource_directory(&request.uri) + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourcesResult::with_all_items( + self.catalog.resources(), + )) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + self.catalog + .read_resource(&request.uri) + .map(Into::into) + .ok_or_else(|| ErrorData::resource_not_found(request.uri, None)) + } +} + +#[derive(Debug, Clone, Default)] +struct DefaultClient; + +impl ClientHandler for DefaultClient { + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } +} + +type Client = RunningService; +type ServerTask = JoinHandle>; + +/// Serves the catalog of `dir` to a client on 2026-07-28 over a duplex pipe. +fn connect(dir: &TempDir) -> (Client, ServerTask) { + let server = CatalogServer { + catalog: Arc::new(load(dir)), + }; + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server_info = InitializeResult::new(server.get_info().capabilities); + server_info.protocol_version = ProtocolVersion::V_2026_07_28; + let server = serve_directly::( + server, + server_transport, + Some(DefaultClient.get_info()), + ); + let server_task = tokio::spawn(async move { + server.waiting().await?; + anyhow::Ok(()) + }); + let client = serve_directly::( + DefaultClient, + client_transport, + Some(server_info.into()), + ); + (client, server_task) +} + +async fn disconnect(client: Client, server_task: ServerTask) { + client.cancel().await.expect("cancel"); + server_task.await.expect("join").expect("server"); +} + +#[tokio::test] +async fn a_client_should_list_every_catalog_skill() { + let dir = skills_tree(); + let (client, server_task) = connect(&dir); + + let skills = client.list_all_skills().await.expect("list_all_skills"); + disconnect(client, server_task).await; + + assert_eq!( + uris(&skills), + [BILLING_SKILL_URI, REFUNDS_SKILL_URI, PDF_SKILL_URI] + ); +} + +#[tokio::test] +async fn a_client_should_read_a_catalog_directory() { + let dir = skills_tree(); + let (client, server_task) = connect(&dir); + + let children = client + .read_resource_directory(ReadResourceDirectoryRequestParams::new(TEMPLATES_DIR_URI)) + .await + .expect("read_resource_directory") + .resources; + disconnect(client, server_task).await; + + let listed: Vec<&str> = children.iter().map(|child| child.uri.as_str()).collect(); + assert_eq!(listed, [INVOICE_URI]); +} + +#[tokio::test] +async fn a_client_should_verify_every_file_of_a_catalog_skill() { + let dir = skills_tree(); + let (client, server_task) = connect(&dir); + + let skill = client + .get_skill(GetSkillRequestParams::new(REFUNDS_SKILL_URI)) + .await + .expect("get_skill") + .skill; + let SkillResources::Files(files) = skill.resources else { + panic!("expected a file manifest"); + }; + let mut verified = Vec::new(); + for file in &files { + let ReadResourceResult { contents, .. } = client + .read_resource(ReadResourceRequestParams::new(&file.uri)) + .await + .expect("read_resource"); + let ResourceContents::TextResourceContents { text, .. } = &contents[0] else { + panic!("expected text"); + }; + verified.push(file.verify(text.as_bytes()).is_ok()); + } + disconnect(client, server_task).await; + + assert_eq!(verified, [true, true]); +}