use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use indexmap::IndexMap;
use serde_json::json;
use crate::error::{Error, ErrorCode, Result};
use crate::server::cancellation::RequestHandlerExtra;
use crate::server::{PromptHandler, ResourceHandler};
use crate::types::content::Role;
use crate::types::{
Content, GetPromptResult, ListResourcesResult, PromptMessage, ReadResourceResult, ResourceInfo,
};
pub(crate) const SKILLS_EXTENSION_KEY: &str = "io.modelcontextprotocol/skills";
const SKILL_INDEX_URI: &str = "skill://index.json";
const SKILL_MD_MIME: &str = "text/markdown";
const INDEX_JSON_MIME: &str = "application/json";
pub(crate) fn set_skills_capabilities(caps: &mut crate::types::ServerCapabilities) {
if caps.resources.is_none() {
caps.resources = Some(crate::types::ResourceCapabilities {
subscribe: Some(false),
list_changed: Some(false),
});
}
caps.extensions
.get_or_insert_with(HashMap::new)
.entry(SKILLS_EXTENSION_KEY.to_string())
.or_insert_with(|| json!({}));
}
#[derive(Clone, Debug)]
pub struct SkillReference {
relative_path: String,
mime_type: String,
body: String,
}
impl SkillReference {
pub fn new(
relative_path: impl Into<String>,
mime_type: impl Into<String>,
body: impl Into<String>,
) -> Self {
Self {
relative_path: relative_path.into(),
mime_type: mime_type.into(),
body: body.into(),
}
}
pub fn relative_path(&self) -> &str {
&self.relative_path
}
pub fn mime_type(&self) -> &str {
&self.mime_type
}
pub fn body(&self) -> &str {
&self.body
}
}
#[derive(Clone, Debug)]
pub struct Skill {
name: String,
body: String,
path: Option<String>,
description: String,
references: Vec<SkillReference>,
}
impl Skill {
pub fn new(name: impl Into<String>, body: impl Into<String>) -> Self {
let body = body.into();
let description = parse_frontmatter_description(&body).unwrap_or_default();
Self {
name: name.into(),
body,
path: None,
description,
references: Vec::new(),
}
}
#[must_use]
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
#[must_use]
pub fn with_reference(self, reference: SkillReference) -> Self {
match self.try_with_reference(reference) {
Ok(s) => s,
Err(e) => panic!("Skill::with_reference: {e}"),
}
}
pub fn try_with_reference(mut self, reference: SkillReference) -> Result<Self> {
validate_reference_path(&reference.relative_path, &self.references)?;
self.references.push(reference);
Ok(self)
}
pub fn name(&self) -> &str {
&self.name
}
pub fn body(&self) -> &str {
&self.body
}
pub fn references(&self) -> impl Iterator<Item = &SkillReference> {
self.references.iter()
}
pub fn resolved_description(&self) -> &str {
&self.description
}
pub(crate) fn resolved_path(&self) -> &str {
self.path.as_deref().unwrap_or(&self.name)
}
pub(crate) fn skill_md_uri(&self) -> String {
format!("skill://{}/SKILL.md", self.resolved_path())
}
pub(crate) fn reference_uri(&self, relative_path: &str) -> String {
format!("skill://{}/{}", self.resolved_path(), relative_path)
}
pub fn as_prompt_text(&self) -> String {
let mut out = String::new();
out.push_str(&self.body);
if !self.body.ends_with('\n') {
out.push('\n');
}
for r in &self.references {
out.push_str("\n--- ");
out.push_str(&r.relative_path);
out.push_str(" ---\n");
out.push_str(&r.body);
if !r.body.ends_with('\n') {
out.push('\n');
}
}
out
}
}
fn validate_reference_path(path: &str, existing: &[SkillReference]) -> Result<()> {
if path.is_empty() {
return Err(Error::validation(
"SkillReference relative_path must not be empty",
));
}
if path.contains('\0') {
return Err(Error::validation(
"SkillReference relative_path must not contain null bytes",
));
}
if path == "SKILL.md" {
return Err(Error::validation(
"SkillReference relative_path 'SKILL.md' collides with the canonical SKILL.md URI",
));
}
if path.split('/').any(|seg| seg == "..") {
return Err(Error::validation(format!(
"SkillReference relative_path '{path}' must not contain '..' segments"
)));
}
if path.starts_with('/') {
return Err(Error::validation(format!(
"SkillReference relative_path '{path}' must be relative (no leading '/')"
)));
}
if path.contains("://") {
return Err(Error::validation(format!(
"SkillReference relative_path '{path}' must not contain a URI scheme"
)));
}
if existing.iter().any(|r| r.relative_path == path) {
return Err(Error::validation(format!(
"SkillReference relative_path '{path}' is already registered on this Skill"
)));
}
Ok(())
}
#[derive(Default, Clone, Debug)]
pub struct Skills {
skills: Vec<Skill>,
}
impl Skills {
pub fn new() -> Self {
Self { skills: Vec::new() }
}
#[must_use]
#[allow(clippy::should_implement_trait)] pub fn add(mut self, skill: Skill) -> Self {
self.skills.push(skill);
self
}
#[must_use]
pub fn merge(mut self, other: Self) -> Self {
self.skills.extend(other.skills);
self
}
pub fn skill_md_uris(&self) -> Vec<String> {
self.skills.iter().map(Skill::skill_md_uri).collect()
}
pub fn into_handler(self) -> Result<Arc<dyn ResourceHandler>> {
let mut skill_md: IndexMap<String, Skill> = IndexMap::with_capacity(self.skills.len());
let mut references: IndexMap<String, (String, String)> = IndexMap::new();
let mut dup_skill: Vec<String> = Vec::new();
let mut dup_ref: Vec<String> = Vec::new();
for skill in self.skills {
for r in &skill.references {
let uri = skill.reference_uri(&r.relative_path);
match references.entry(uri) {
indexmap::map::Entry::Occupied(e) => dup_ref.push(e.key().clone()),
indexmap::map::Entry::Vacant(e) => {
e.insert((r.mime_type.clone(), r.body.clone()));
},
}
}
let uri = skill.skill_md_uri();
match skill_md.entry(uri) {
indexmap::map::Entry::Occupied(e) => dup_skill.push(e.key().clone()),
indexmap::map::Entry::Vacant(e) => {
e.insert(skill);
},
}
}
if !dup_skill.is_empty() || !dup_ref.is_empty() {
let mut msg = String::from("Skills::into_handler: duplicate URI(s):");
if !dup_skill.is_empty() {
msg.push_str(&format!(" SKILL.md=[{}]", dup_skill.join(", ")));
}
if !dup_ref.is_empty() {
msg.push_str(&format!(" references=[{}]", dup_ref.join(", ")));
}
return Err(Error::validation(msg));
}
Ok(Arc::new(SkillsHandler::new(skill_md, references)))
}
}
pub(crate) struct SkillsHandler {
list_resources: Vec<ResourceInfo>,
skill_md: IndexMap<String, Skill>,
references: IndexMap<String, (String, String)>, index_json: String,
}
impl SkillsHandler {
fn new(
skill_md: IndexMap<String, Skill>,
references: IndexMap<String, (String, String)>,
) -> Self {
let mut list_resources: Vec<ResourceInfo> = skill_md
.values()
.map(|s| {
ResourceInfo::new(s.skill_md_uri(), s.name().to_string())
.with_description(s.resolved_description())
.with_mime_type(SKILL_MD_MIME)
})
.collect();
list_resources.push(
ResourceInfo::new(SKILL_INDEX_URI, "index")
.with_description("Skill discovery index (SEP-2640 §9)")
.with_mime_type(INDEX_JSON_MIME),
);
let index_json = build_discovery_index_json(&skill_md);
Self {
list_resources,
skill_md,
references,
index_json,
}
}
}
fn build_discovery_index_json(skill_md: &IndexMap<String, Skill>) -> String {
let entries: Vec<_> = skill_md
.values()
.map(|s| {
json!({
"name": s.name(),
"type": "skill-md",
"description": s.resolved_description(),
"url": s.skill_md_uri(),
})
})
.collect();
serde_json::to_string_pretty(&json!({
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": entries,
}))
.expect("static JSON object — to_string_pretty cannot fail")
}
#[async_trait]
impl ResourceHandler for SkillsHandler {
async fn list(
&self,
_cursor: Option<String>,
_extra: RequestHandlerExtra,
) -> Result<ListResourcesResult> {
Ok(ListResourcesResult::new(self.list_resources.clone()))
}
async fn read(&self, uri: &str, _extra: RequestHandlerExtra) -> Result<ReadResourceResult> {
if uri == SKILL_INDEX_URI {
return Ok(ReadResourceResult::new(vec![Content::resource_with_text(
uri,
self.index_json.clone(),
INDEX_JSON_MIME,
)]));
}
if let Some(skill) = self.skill_md.get(uri) {
return Ok(ReadResourceResult::new(vec![Content::resource_with_text(
uri,
skill.body().to_string(),
SKILL_MD_MIME,
)]));
}
if let Some((mime, body)) = self.references.get(uri) {
return Ok(ReadResourceResult::new(vec![Content::resource_with_text(
uri,
body.clone(),
mime.clone(),
)]));
}
Err(Error::protocol(
ErrorCode::METHOD_NOT_FOUND,
format!("Skill resource not found: {uri}"),
))
}
}
pub(crate) struct SkillPromptHandler {
prompt_text: String,
description: String,
}
impl SkillPromptHandler {
pub(crate) fn new(skill: Skill) -> Self {
let prompt_text = skill.as_prompt_text();
let description = skill.resolved_description().to_string();
Self {
prompt_text,
description,
}
}
}
#[async_trait]
impl PromptHandler for SkillPromptHandler {
async fn handle(
&self,
_args: HashMap<String, String>,
_extra: RequestHandlerExtra,
) -> Result<GetPromptResult> {
let message = PromptMessage::new(Role::User, Content::text(self.prompt_text.clone()));
Ok(GetPromptResult::new(
vec![message],
Some(self.description.clone()),
))
}
}
pub(crate) struct ComposedResources {
pub(crate) skills: Arc<dyn ResourceHandler>,
pub(crate) other: Arc<dyn ResourceHandler>,
}
#[async_trait]
impl ResourceHandler for ComposedResources {
async fn list(
&self,
cursor: Option<String>,
extra: RequestHandlerExtra,
) -> Result<ListResourcesResult> {
let mut combined = self
.skills
.list(None, RequestHandlerExtra::default())
.await?;
let extra_other = self.other.list(cursor, extra).await?;
combined.resources.extend(extra_other.resources);
Ok(combined)
}
async fn read(&self, uri: &str, extra: RequestHandlerExtra) -> Result<ReadResourceResult> {
if uri.starts_with("skill://") {
self.skills.read(uri, extra).await
} else {
self.other.read(uri, extra).await
}
}
}
fn parse_frontmatter_description(body: &str) -> Option<String> {
let body = body.strip_prefix('\u{FEFF}').unwrap_or(body);
let mut in_frontmatter = false;
for line in body.lines().take(40) {
if line == "---" {
if in_frontmatter {
break;
}
in_frontmatter = true;
continue;
}
if in_frontmatter {
if let Some(rest) = line.strip_prefix("description: ") {
return Some(rest.trim().to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn extra() -> RequestHandlerExtra {
RequestHandlerExtra::default()
}
#[test]
fn test_1_1_skill_new_and_builders() {
let s = Skill::new("foo", "body");
assert_eq!(s.name(), "foo");
assert_eq!(s.body(), "body");
assert_eq!(s.references().count(), 0);
assert_eq!(s.resolved_description(), "");
let s = s
.with_path("p")
.with_description("d")
.with_reference(SkillReference::new(
"references/x.md",
"text/markdown",
"ref body",
));
assert_eq!(s.resolved_path(), "p");
assert_eq!(s.resolved_description(), "d");
assert_eq!(s.references().count(), 1);
}
#[test]
fn test_1_2_skill_md_uri_default_and_override() {
let s = Skill::new("foo", "");
assert_eq!(s.skill_md_uri(), "skill://foo/SKILL.md");
let s = s.with_path("acme/refunds");
assert_eq!(s.skill_md_uri(), "skill://acme/refunds/SKILL.md");
}
#[test]
fn test_1_3_skill_reference_uri_resolution() {
let s = Skill::new("x", "").with_reference(SkillReference::new(
"references/a.md",
"text/markdown",
"...",
));
assert_eq!(
s.reference_uri("references/a.md"),
"skill://x/references/a.md"
);
let s = s.with_path("y/z");
assert_eq!(
s.reference_uri("references/a.md"),
"skill://y/z/references/a.md"
);
}
#[test]
fn test_1_4_as_prompt_text_no_references() {
let s = Skill::new("x", "---\nname: x\n---\nbody");
assert_eq!(s.as_prompt_text(), "---\nname: x\n---\nbody\n");
}
#[test]
fn test_1_5_as_prompt_text_with_references() {
let s = Skill::new("x", "A").with_reference(SkillReference::new(
"ref1.md",
"text/markdown",
"refbody",
));
assert_eq!(s.as_prompt_text(), "A\n\n--- ref1.md ---\nrefbody\n");
let s = Skill::new("x", "A")
.with_reference(SkillReference::new("r1.md", "text/markdown", "b1"))
.with_reference(SkillReference::new("r2.md", "text/markdown", "b2"));
assert_eq!(
s.as_prompt_text(),
"A\n\n--- r1.md ---\nb1\n\n--- r2.md ---\nb2\n"
);
}
#[test]
fn test_1_6_resolved_description_frontmatter_parsing() {
let s = Skill::new("x", "---\nname: x\ndescription: hello\n---\nbody");
assert_eq!(s.resolved_description(), "hello");
let s = Skill::new("x", "---\nname: x\ndescription: hello\n---\nbody")
.with_description("override");
assert_eq!(s.resolved_description(), "override");
let s = Skill::new("x", "no frontmatter");
assert_eq!(s.resolved_description(), "");
}
#[test]
fn test_1_6a_parse_frontmatter_crlf() {
let s = Skill::new("x", "---\r\nname: x\r\ndescription: hello\r\n---\r\nbody");
assert_eq!(s.resolved_description(), "hello");
}
#[test]
fn test_1_6b_parse_frontmatter_utf8_bom() {
let s = Skill::new("x", "\u{FEFF}---\nname: x\ndescription: hello\n---\nbody");
assert_eq!(s.resolved_description(), "hello");
}
#[tokio::test]
async fn test_1_7_skills_into_handler_happy_path() {
let handler = Skills::new()
.add(Skill::new("a", ""))
.add(Skill::new("b", ""))
.into_handler()
.unwrap();
let list = handler.list(None, extra()).await.unwrap();
assert_eq!(list.resources.len(), 3);
assert_eq!(list.resources[0].uri, "skill://a/SKILL.md");
assert_eq!(list.resources[1].uri, "skill://b/SKILL.md");
assert_eq!(list.resources[2].uri, "skill://index.json");
for r in &list.resources {
assert!(!r.uri.contains("/references/"));
}
}
#[tokio::test]
async fn test_1_7a_skills_into_handler_preserves_registration_order() {
for _ in 0..10 {
let handler = Skills::new()
.add(Skill::new("zeta", ""))
.add(Skill::new("alpha", ""))
.add(Skill::new("mu", ""))
.into_handler()
.unwrap();
let list = handler.list(None, extra()).await.unwrap();
assert_eq!(list.resources.len(), 4);
assert_eq!(list.resources[0].uri, "skill://zeta/SKILL.md");
assert_eq!(list.resources[1].uri, "skill://alpha/SKILL.md");
assert_eq!(list.resources[2].uri, "skill://mu/SKILL.md");
assert_eq!(list.resources[3].uri, "skill://index.json");
}
}
#[test]
fn test_1_8_skills_into_handler_duplicate_skill_md_uri_rejected() {
match Skills::new()
.add(Skill::new("refunds", "a"))
.add(Skill::new("refunds", "b"))
.into_handler()
{
Err(Error::Validation(msg)) => {
assert!(msg.contains("skill://refunds/SKILL.md"), "msg = {msg}");
},
Err(other) => panic!("expected Validation, got {other:?}"),
Ok(_) => panic!("expected Err for duplicate names"),
}
match Skills::new()
.add(Skill::new("a", "").with_path("p"))
.add(Skill::new("b", "").with_path("p"))
.into_handler()
{
Err(Error::Validation(msg)) => assert!(msg.contains("skill://p/SKILL.md")),
Err(other) => panic!("expected Validation, got {other:?}"),
Ok(_) => panic!("expected Err for colliding paths"),
}
}
#[test]
fn test_1_8a_skills_into_handler_duplicate_reference_uri_rejected() {
let s1 = Skill::new("a", "").with_reference(SkillReference::new(
"references/shared.md",
"text/markdown",
"x",
));
let s2 = Skill::new("b", "")
.with_path("a")
.with_reference(SkillReference::new(
"references/shared.md",
"text/markdown",
"y",
));
match Skills::new().add(s1).add(s2).into_handler() {
Err(Error::Validation(msg)) => {
assert!(
msg.contains("skill://a/references/shared.md"),
"msg = {msg}"
);
assert!(msg.contains("references="), "msg = {msg}");
},
Err(other) => panic!("expected Validation, got {other:?}"),
Ok(_) => panic!("expected Err for colliding reference URIs"),
}
}
#[tokio::test]
async fn test_1_9_skills_handler_list_excludes_references() {
let s = Skill::new("a", "")
.with_reference(SkillReference::new(
"references/r1.md",
"text/markdown",
"1",
))
.with_reference(SkillReference::new(
"references/r2.md",
"text/markdown",
"2",
));
let handler = Skills::new().add(s).into_handler().unwrap();
let list = handler.list(None, extra()).await.unwrap();
let skill_md_count = list
.resources
.iter()
.filter(|r| r.uri == "skill://a/SKILL.md")
.count();
assert_eq!(skill_md_count, 1);
for r in &list.resources {
assert!(!r.uri.contains("/references/"), "leaked: {}", r.uri);
}
let index_count = list
.resources
.iter()
.filter(|r| r.uri == "skill://index.json")
.count();
assert_eq!(index_count, 1);
}
#[tokio::test]
async fn test_1_10_skills_handler_read_skill_md_returns_resource_with_text() {
let handler = Skills::new()
.add(Skill::new("a", "the body"))
.into_handler()
.unwrap();
let res = handler.read("skill://a/SKILL.md", extra()).await.unwrap();
assert_eq!(res.contents.len(), 1);
match &res.contents[0] {
Content::Resource {
uri,
text,
mime_type,
..
} => {
assert_eq!(uri, "skill://a/SKILL.md");
assert_eq!(text.as_deref(), Some("the body"));
assert_eq!(mime_type.as_deref(), Some("text/markdown"));
},
other => panic!("expected Content::Resource, got {other:?}"),
}
}
#[tokio::test]
async fn test_1_11_skills_handler_read_reference_carries_per_resource_mime() {
let s = Skill::new("a", "").with_reference(SkillReference::new(
"references/schema.graphql",
"application/graphql",
"schema { query: Q }",
));
let handler = Skills::new().add(s).into_handler().unwrap();
let res = handler
.read("skill://a/references/schema.graphql", extra())
.await
.unwrap();
match &res.contents[0] {
Content::Resource {
uri,
text,
mime_type,
..
} => {
assert_eq!(uri, "skill://a/references/schema.graphql");
assert_eq!(text.as_deref(), Some("schema { query: Q }"));
assert_eq!(mime_type.as_deref(), Some("application/graphql"));
},
other => panic!("expected Content::Resource, got {other:?}"),
}
}
#[tokio::test]
async fn test_1_12_skills_handler_read_index_returns_resource_with_text() {
let s = Skill::new("a", "").with_reference(SkillReference::new(
"references/r.md",
"text/markdown",
"x",
));
let handler = Skills::new().add(s).into_handler().unwrap();
let res = handler.read("skill://index.json", extra()).await.unwrap();
match &res.contents[0] {
Content::Resource {
uri,
text,
mime_type,
..
} => {
assert_eq!(uri, "skill://index.json");
assert_eq!(mime_type.as_deref(), Some("application/json"));
let parsed: serde_json::Value =
serde_json::from_str(text.as_deref().unwrap()).unwrap();
assert!(parsed.get("$schema").is_some());
assert!(parsed.get("skills").is_some());
let arr = parsed["skills"].as_array().unwrap();
assert_eq!(arr.len(), 1);
let serialized = serde_json::to_string(&parsed).unwrap();
assert!(!serialized.contains("references/r.md"));
},
other => panic!("expected Content::Resource, got {other:?}"),
}
}
#[tokio::test]
async fn test_1_13_skills_handler_read_unknown_uri_method_not_found() {
let handler = Skills::new()
.add(Skill::new("a", "body"))
.into_handler()
.unwrap();
let err = handler
.read("skill://nonexistent/SKILL.md", extra())
.await
.expect_err("unknown URI must error");
match err {
Error::Protocol { code, .. } => assert_eq!(code, ErrorCode::METHOD_NOT_FOUND),
other => panic!("expected Protocol, got {other:?}"),
}
let err = handler
.read("skill://a/references/missing.md", extra())
.await
.expect_err("unknown reference must error");
match err {
Error::Protocol { code, .. } => assert_eq!(code, ErrorCode::METHOD_NOT_FOUND),
other => panic!("expected Protocol, got {other:?}"),
}
}
#[tokio::test]
async fn test_1_14_skill_prompt_handler_returns_byte_equal_text() {
let skill = Skill::new("x", "A").with_reference(SkillReference::new(
"ref1.md",
"text/markdown",
"refbody",
));
let handler = SkillPromptHandler::new(skill.clone());
let result = handler.handle(HashMap::new(), extra()).await.unwrap();
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].role, Role::User);
match &result.messages[0].content {
Content::Text { text } => assert_eq!(text, &skill.as_prompt_text()),
other => panic!("expected Content::Text, got {other:?}"),
}
}
struct DocsHandler;
#[async_trait]
impl ResourceHandler for DocsHandler {
async fn read(&self, uri: &str, _extra: RequestHandlerExtra) -> Result<ReadResourceResult> {
Ok(ReadResourceResult::new(vec![Content::text(format!(
"DOCS:{uri}"
))]))
}
async fn list(
&self,
_cursor: Option<String>,
_extra: RequestHandlerExtra,
) -> Result<ListResourcesResult> {
Ok(ListResourcesResult::new(vec![ResourceInfo::new(
"docs://handbook",
"handbook",
)]))
}
}
#[tokio::test]
async fn test_1_15_composed_resources_uri_prefix_routing() {
let skills: Arc<dyn ResourceHandler> = Skills::new()
.add(Skill::new("a", "skill-a"))
.into_handler()
.unwrap();
let other: Arc<dyn ResourceHandler> = Arc::new(DocsHandler);
let composed = ComposedResources { skills, other };
let res = composed.read("skill://a/SKILL.md", extra()).await.unwrap();
match &res.contents[0] {
Content::Resource { uri, .. } => assert_eq!(uri, "skill://a/SKILL.md"),
other => panic!("expected Content::Resource, got {other:?}"),
}
let res = composed.read("docs://handbook", extra()).await.unwrap();
match &res.contents[0] {
Content::Text { text } => assert_eq!(text, "DOCS:docs://handbook"),
other => panic!("expected Content::Text, got {other:?}"),
}
let res = composed.read("ftp://foo", extra()).await.unwrap();
match &res.contents[0] {
Content::Text { text } => assert_eq!(text, "DOCS:ftp://foo"),
other => panic!("expected Content::Text, got {other:?}"),
}
}
#[tokio::test]
async fn test_1_16_composed_resources_list_concatenates_skills_first() {
let skills: Arc<dyn ResourceHandler> = Skills::new()
.add(Skill::new("a", ""))
.into_handler()
.unwrap();
let other: Arc<dyn ResourceHandler> = Arc::new(DocsHandler);
let composed = ComposedResources { skills, other };
let list = composed.list(None, extra()).await.unwrap();
assert_eq!(list.resources.len(), 3);
assert_eq!(list.resources[0].uri, "skill://a/SKILL.md");
assert_eq!(list.resources[1].uri, "skill://index.json");
assert_eq!(list.resources[2].uri, "docs://handbook");
}
fn skill_strategy() -> impl Strategy<Value = Skill> {
let name = "[a-z]{1,8}";
let ref_strategy = (
"ref_[a-z]{1,6}\\.md",
Just("text/markdown".to_string()),
"[a-zA-Z]{1,12}",
)
.prop_map(|(p, m, b)| SkillReference::new(p, m, b));
(
name,
"[a-zA-Z]{0,20}",
proptest::collection::vec(ref_strategy, 0..=5),
)
.prop_map(|(name, body, refs)| {
let mut s = Skill::new(name, body);
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for r in refs {
if seen.insert(r.relative_path().to_string()) {
s = s.with_reference(r);
}
}
s
})
}
fn skills_strategy_with_refs() -> impl Strategy<Value = Vec<Skill>> {
proptest::collection::vec(skill_strategy(), 1..=10).prop_map(|skills| {
skills
.into_iter()
.enumerate()
.map(|(i, s)| {
let new_path = format!("p{i}");
let mut rebuilt = Skill::new(s.name().to_string(), s.body().to_string())
.with_path(new_path)
.with_description(s.resolved_description());
for r in s.references() {
rebuilt = rebuilt.with_reference(SkillReference::new(
r.relative_path(),
r.mime_type(),
r.body(),
));
}
rebuilt
})
.collect()
})
}
proptest! {
#[test]
fn prop_1_17_no_reference_ever_listed(skills in skills_strategy_with_refs()) {
let mut registry = Skills::new();
for s in skills {
registry = registry.add(s);
}
let Ok(handler) = registry.into_handler() else { return Ok(()); };
let rt = tokio::runtime::Runtime::new().unwrap();
let list = rt.block_on(handler.list(None, RequestHandlerExtra::default())).unwrap();
for r in &list.resources {
prop_assert!(!r.uri.contains("/references/"), "leaked: {}", r.uri);
}
}
}
proptest! {
#[test]
fn prop_1_18_duplicate_uri_always_rejected(
name in "[a-z]{1,6}",
body_a in "[a-zA-Z]{0,12}",
body_b in "[a-zA-Z]{0,12}",
) {
let result = Skills::new()
.add(Skill::new(name.clone(), body_a))
.add(Skill::new(name, body_b))
.into_handler();
prop_assert!(result.is_err());
}
#[test]
fn prop_1_18b_distinct_names_always_ok(
name_a in "[a-z]{1,6}",
name_b in "[a-z]{7,12}",
) {
prop_assume!(name_a != name_b);
let result = Skills::new()
.add(Skill::new(name_a, ""))
.add(Skill::new(name_b, ""))
.into_handler();
prop_assert!(result.is_ok());
}
}
proptest! {
#[test]
fn prop_1_19_as_prompt_text_byte_equal_concat(skill in skill_strategy()) {
let mut expected = String::new();
expected.push_str(skill.body());
if !skill.body().ends_with('\n') {
expected.push('\n');
}
for r in skill.references() {
expected.push_str("\n--- ");
expected.push_str(r.relative_path());
expected.push_str(" ---\n");
expected.push_str(r.body());
if !r.body().ends_with('\n') {
expected.push('\n');
}
}
prop_assert_eq!(skill.as_prompt_text(), expected);
}
}
fn collect_all_uris(skills: &[Skill]) -> Vec<String> {
let mut uris: Vec<String> = vec!["skill://index.json".to_string()];
for s in skills {
uris.push(s.skill_md_uri());
for r in s.references() {
uris.push(s.reference_uri(r.relative_path()));
}
}
uris
}
fn assert_read_response_has_uri_and_mime(
contents: &[Content],
expected_uri: &str,
) -> std::result::Result<(), proptest::test_runner::TestCaseError> {
prop_assert_eq!(contents.len(), 1);
match &contents[0] {
Content::Resource {
uri,
text,
mime_type,
..
} => {
prop_assert_eq!(uri, expected_uri);
prop_assert!(text.is_some(), "text missing for {}", expected_uri);
prop_assert!(mime_type.is_some(), "mime missing for {}", expected_uri);
Ok(())
},
other => {
prop_assert!(false, "expected Content::Resource, got {:?}", other);
Ok(())
},
}
}
proptest! {
#[test]
fn prop_1_19a_read_responses_always_have_uri_and_mime(skills in skills_strategy_with_refs()) {
let mut registry = Skills::new();
for s in skills.clone() {
registry = registry.add(s);
}
let Ok(handler) = registry.into_handler() else { return Ok(()); };
let rt = tokio::runtime::Runtime::new().unwrap();
let uris = collect_all_uris(&skills);
for uri in uris {
let Ok(res) = rt.block_on(handler.read(&uri, RequestHandlerExtra::default())) else { continue; };
assert_read_response_has_uri_and_mime(&res.contents, &uri)?;
}
}
}
#[test]
#[should_panic(expected = "must not be empty")]
fn test_1_20_with_reference_panic_empty() {
let _ =
Skill::new("x", "b").with_reference(SkillReference::new("", "text/markdown", "body"));
}
#[test]
#[should_panic(expected = "SKILL.md")]
fn test_1_20_with_reference_panic_skill_md_collision() {
let _ = Skill::new("x", "b").with_reference(SkillReference::new(
"SKILL.md",
"text/markdown",
"body",
));
}
#[test]
#[should_panic(expected = "..")]
fn test_1_20_with_reference_panic_dotdot() {
let _ = Skill::new("x", "b").with_reference(SkillReference::new(
"../escape.md",
"text/markdown",
"body",
));
}
#[test]
#[should_panic(expected = "leading")]
fn test_1_20_with_reference_panic_absolute() {
let _ = Skill::new("x", "b").with_reference(SkillReference::new(
"/abs/path.md",
"text/markdown",
"body",
));
}
#[test]
#[should_panic(expected = "URI scheme")]
fn test_1_20_with_reference_panic_scheme() {
let _ = Skill::new("x", "b").with_reference(SkillReference::new(
"http://example.com/x",
"text/markdown",
"body",
));
}
#[test]
#[should_panic(expected = "already registered")]
fn test_1_20_with_reference_panic_duplicate_within_skill() {
let _ = Skill::new("x", "b")
.with_reference(SkillReference::new("a.md", "text/markdown", "body1"))
.with_reference(SkillReference::new("a.md", "text/markdown", "body2"));
}
#[test]
fn test_1_20a_try_with_reference_returns_err() {
let invalid = [
"",
"SKILL.md",
"../escape.md",
"/abs/path.md",
"http://example.com/x",
];
for p in invalid {
let res = Skill::new("x", "b").try_with_reference(SkillReference::new(
p,
"text/markdown",
"body",
));
assert!(res.is_err(), "expected Err for path = {p:?}");
assert!(matches!(res.unwrap_err(), Error::Validation(_)));
}
let res = Skill::new("x", "b")
.try_with_reference(SkillReference::new("a.md", "text/markdown", "1"))
.and_then(|s| s.try_with_reference(SkillReference::new("a.md", "text/markdown", "2")));
assert!(res.is_err());
let res = Skill::new("x", "b").try_with_reference(SkillReference::new(
"references/ok.md",
"text/markdown",
"body",
));
assert!(res.is_ok());
}
#[tokio::test]
async fn test_1_21_skills_merge_concatenates() {
let combined = Skills::new()
.add(Skill::new("a", ""))
.merge(Skills::new().add(Skill::new("b", "")));
let handler = combined.into_handler().unwrap();
let list = handler.list(None, extra()).await.unwrap();
assert_eq!(list.resources.len(), 3);
assert_eq!(list.resources[0].uri, "skill://a/SKILL.md");
assert_eq!(list.resources[1].uri, "skill://b/SKILL.md");
assert_eq!(list.resources[2].uri, "skill://index.json");
}
}