From d8ad7fbddc30d2d64bea8132717cc83978095760 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Tue, 15 Sep 2026 10:29:01 -0700 Subject: [PATCH 1/3] feat(rmcp): implement SEP-2640 MCP Skills Extension Add skills/list, skills/get, resources/directory/read server-side dispatch and client-side convenience wrappers (list_skills, get_skill, read_skill_uri, read_directory, list_all_skills). ServerHandler trait default methods + handle_request arms for all three RPC methods. Model types in new model/skills.rs module with const_string! request method constants, PaginatedRequestParams-based SkillsListRequest, and ResourcesDirectoryReadRequestParams/Result mirroring the resources/read pattern. Client wrappers on Peer plus spec-named public API convenience functions. Co-Authored-By: openhands --- crates/rmcp/src/handler/server.rs | 60 ++++ crates/rmcp/src/model.rs | 34 +- crates/rmcp/src/model/meta.rs | 6 + crates/rmcp/src/model/skills.rs | 568 ++++++++++++++++++++++++++++++ crates/rmcp/src/service/client.rs | 173 ++++++++- 5 files changed, 833 insertions(+), 8 deletions(-) create mode 100644 crates/rmcp/src/model/skills.rs diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index a6dd83bfc..89154f09b 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -144,6 +144,18 @@ impl Service for H { .read_resource(request.params, context) .await .map(ServerResult::from), + ClientRequest::ResourcesDirectoryReadRequest(request) => self + .resources_directory_read(request.params, context) + .await + .map(ServerResult::ResourcesDirectoryReadResult), + ClientRequest::SkillsListRequest(request) => self + .skills_list(request.params, context) + .await + .map(ServerResult::SkillsListResult), + ClientRequest::SkillsGetRequest(request) => self + .skills_get(request.params, context) + .await + .map(ServerResult::SkillsGetResult), ClientRequest::SubscriptionsListenRequest(request) => { if legacy_request { Err(McpError::method_not_found::()) @@ -452,6 +464,29 @@ macro_rules! server_handler_methods { McpError::method_not_found::(), )) } + fn skills_list( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(SkillsListResult::default())) + } + fn skills_get( + &self, + request: SkillsGetRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } + fn resources_directory_read( + &self, + request: ResourcesDirectoryReadRequestParams, + context: RequestContext, + ) -> impl Future> + + MaybeSendFuture + + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } /// Return the subset of a requested notification filter this server accepts. /// /// Returning `None` leaves `subscriptions/listen` unimplemented. The SDK @@ -843,6 +878,31 @@ macro_rules! impl_server_handler_for_wrapper { (**self).get_info() } + fn skills_list( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).skills_list(request, context) + } + + fn skills_get( + &self, + request: SkillsGetRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).skills_get(request, context) + } + + fn resources_directory_read( + &self, + request: ResourcesDirectoryReadRequestParams, + context: RequestContext, + ) -> impl Future> + { + (**self).resources_directory_read(request, context) + } + fn get_task( &self, request: GetTaskParams, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 9981658bf..765010d25 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -20,6 +20,7 @@ mod prompt; mod request_state; mod resource; mod serde_impl; +mod skills; mod task; mod tool; pub use annotated::*; @@ -35,6 +36,7 @@ pub use request_state::*; pub use resource::*; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; +pub use skills::*; pub use task::*; pub use tool::*; @@ -4505,6 +4507,9 @@ ts_union!( | ListResourcesRequest | ListResourceTemplatesRequest | ReadResourceRequest + | ResourcesDirectoryReadRequest + | SkillsGetRequest + | SkillsListRequest | SubscriptionsListenRequest | SubscribeRequest | UnsubscribeRequest @@ -4529,6 +4534,9 @@ impl ClientRequest { ClientRequest::ListResourcesRequest(r) => r.method.as_str(), ClientRequest::ListResourceTemplatesRequest(r) => r.method.as_str(), ClientRequest::ReadResourceRequest(r) => r.method.as_str(), + ClientRequest::ResourcesDirectoryReadRequest(r) => r.method.as_str(), + ClientRequest::SkillsGetRequest(r) => r.method.as_str(), + ClientRequest::SkillsListRequest(r) => r.method.as_str(), ClientRequest::SubscriptionsListenRequest(r) => r.method.as_str(), ClientRequest::SubscribeRequest(r) => r.method.as_str(), ClientRequest::UnsubscribeRequest(r) => r.method.as_str(), @@ -4542,6 +4550,21 @@ impl ClientRequest { } } +impl ServerRequest { + pub fn method(&self) -> &str { + match &self { + ServerRequest::PingRequest(r) => r.method.as_str(), + ServerRequest::CreateMessageRequest(r) => r.method.as_str(), + ServerRequest::ListRootsRequest(r) => r.method.as_str(), + ServerRequest::ElicitRequest(r) => r.method.as_str(), + ServerRequest::CustomRequest(r) => r.method.as_str(), + ServerRequest::ResourcesDirectoryReadRequest(r) => r.method.as_str(), + ServerRequest::SkillsGetRequest(r) => r.method.as_str(), + ServerRequest::SkillsListRequest(r) => r.method.as_str(), + } + } +} + ts_union!( export type ClientNotification = | CancelledNotification @@ -4574,7 +4597,10 @@ ts_union!( | CreateMessageRequest | ListRootsRequest | ElicitRequest - | CustomRequest; + | CustomRequest + | ResourcesDirectoryReadRequest + | SkillsGetRequest + | SkillsListRequest; ); ts_union!( @@ -4601,6 +4627,9 @@ ts_union!( | ListResourcesResult | ListResourceTemplatesResult | ReadResourceResult + | ResourcesDirectoryReadResult + | SkillsGetResult + | SkillsListResult | SubscriptionsListenResult | ListToolsResult | ElicitResult @@ -4657,11 +4686,12 @@ impl ServerResult { ServerResult::ListResourcesResult(r) => &mut r.result_type, ServerResult::ListResourceTemplatesResult(r) => &mut r.result_type, ServerResult::ReadResourceResult(r) => &mut r.result_type, + ServerResult::ResourcesDirectoryReadResult(r) => &mut r.result_type, ServerResult::ListToolsResult(r) => &mut r.result_type, ServerResult::CallToolResult(r) => &mut r.result_type, _ => return, }; - result_type.take_if(|result_type| result_type.is_complete()); + result_type.take_if(|rt| rt.is_complete()); } } diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 0a7121e4a..385d42ff7 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -182,6 +182,9 @@ variant_extension! { ListResourcesRequest ListResourceTemplatesRequest ReadResourceRequest + ResourcesDirectoryReadRequest + SkillsGetRequest + SkillsListRequest SubscriptionsListenRequest SubscribeRequest UnsubscribeRequest @@ -201,6 +204,9 @@ variant_extension! { ListRootsRequest ElicitRequest CustomRequest + ResourcesDirectoryReadRequest + SkillsGetRequest + SkillsListRequest } } diff --git a/crates/rmcp/src/model/skills.rs b/crates/rmcp/src/model/skills.rs new file mode 100644 index 000000000..768ec1e29 --- /dev/null +++ b/crates/rmcp/src/model/skills.rs @@ -0,0 +1,568 @@ +// SEP-2640: Skills Extension — data types, request/response types, and URI utilities. +// +// Implements the normative parts of +// https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640 +// +// Skills are transported as MCP Resources. See §Resource Mapping and §Discovery of +// the spec for the exact wire shapes. + +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +use super::{ + ConstString, MetaObject, PaginatedRequestParams, Request, RequestMetaObject, + RequestOptionalParam, ResultType, +}; +use crate::const_string; + +// ============================================================================= +// Skill entry +// ============================================================================= + +/// A skill entry returned by `skills/list` or `skills/get`. +/// +/// Mirrors the spec's JSON shape (see §Enumeration via `skills/list` in SEP-2640): +/// +/// ```json +/// { +/// "uri": "skill://doc-workflow/SKILL.md", +/// "frontmatter": { "name": "doc-workflow", "description": "..." }, +/// "resources": [ +/// { "uri": "skill://doc-workflow/SKILL.md", "digest": "sha256:...", "size": 1234 } +/// ], +/// "_meta": { "ttlMs": 600000, "cacheScope": "metatarsal" } +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SkillEntry { + /// Resource URI of the skill's `SKILL.md`, e.g. `skill://doc-workflow/SKILL.md`. + pub uri: String, + + /// Verbatim copy of the `SKILL.md` YAML frontmatter, rendered as JSON. + /// + /// The spec calls this "the frontmatter properties, rendered as JSON". + /// Required keys: `name` (MUST equal the final segment of the skill path), + /// `description`. Optional: `version`, `license`, plus any custom metadata. + pub frontmatter: serde_json::Value, + + /// Optional array of the skill's files with their URIs, SHA-256 digests, + /// and sizes, or the literal string `"dynamic"` for dynamically-generated + /// skills. Omitted from bare `SKILL.md`-only skills. + #[serde(skip_serializing_if = "Option::is_none")] + pub resources: Option, + + /// Optional protocol-level metadata, including SEP-2549 caching fields. + /// REQUIRED on `skills/list` results in protocol version 2026-07-28+ + /// (SEP-2549); not required on `skills/get` results. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl SkillEntry { + pub fn new(uri: impl Into, frontmatter: serde_json::Value) -> Self { + Self { + uri: uri.into(), + frontmatter, + resources: None, + meta: None, + } + } +} + +/// The `resources` field of a skill entry. +/// +/// The spec allows either a concrete file list or the literal `"dynamic"`. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum SkillResources { + /// Fixed list of file entries with digests and sizes. + FileList(Vec), + /// Dynamic skill whose content is generated on the fly. + /// The wire form is the bare JSON string `"dynamic"`. + Dynamic, +} + +impl Serialize for SkillResources { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::FileList(files) => files.serialize(serializer), + Self::Dynamic => serializer.serialize_str("dynamic"), + } + } +} + +impl<'de> Deserialize<'de> for SkillResources { + fn deserialize>(deserializer: D) -> Result { + use serde::de::{self, MapAccess, SeqAccess, Visitor}; + struct SkillResourcesVisitor; + impl<'de> Visitor<'de> for SkillResourcesVisitor { + type Value = SkillResources; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a skill resources list or the string \"dynamic\"") + } + fn visit_str(self, value: &str) -> Result { + if value == "dynamic" { + Ok(SkillResources::Dynamic) + } else { + Err(de::Error::invalid_type(de::Unexpected::Str(value), &self)) + } + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut files = Vec::new(); + while let Some(file) = seq.next_element::()? { + files.push(file); + } + Ok(SkillResources::FileList(files)) + } + fn visit_map>(self, _map: A) -> Result { + Err(de::Error::invalid_type(de::Unexpected::Map, &self)) + } + } + deserializer.deserialize_any(SkillResourcesVisitor) + } +} + +impl SkillResources { + pub fn is_dynamic(&self) -> bool { + matches!(self, Self::Dynamic) + } + + pub fn as_files(&self) -> Option<&[SkillResource]> { + match self { + Self::FileList(files) => Some(files), + Self::Dynamic => None, + } + } +} + +/// A single file inside a skill, with its URI, SHA-256 digest, and size. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SkillResource { + pub uri: String, + pub digest: String, + pub size: u64, +} + +impl SkillResource { + pub fn new(uri: impl Into, digest: impl Into, size: u64) -> Self { + Self { + uri: uri.into(), + digest: digest.into(), + size, + } + } +} + +// ============================================================================= +// Request / response types +// ============================================================================= + +const_string!(SkillsListRequestMethod = "skills/list"); + +/// Request to list the skills published by a server. +/// +/// The request carries an optional pagination cursor (no required params). +pub type SkillsListRequest = RequestOptionalParam; + +/// Response to `skills/list`. +/// +/// Carries the skill entries for this page plus (for protocol 2026-07-28+) +/// the SEP-2549 caching fields (`ttlMs`/`cacheScope`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SkillsListResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, + pub skills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, +} + +impl Default for SkillsListResult { + fn default() -> Self { + Self::new(Vec::new()) + } +} + +impl SkillsListResult { + pub fn new(skills: Vec) -> Self { + Self { + result_type: Some(ResultType::COMPLETE), + skills, + next_cursor: None, + ttl_ms: None, + cache_scope: None, + } + } +} + +const_string!(SkillsGetRequestMethod = "skills/get"); + +/// Parameters for retrieving a single skill by URI. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SkillsGetRequestParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub uri: String, +} + +impl SkillsGetRequestParams { + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + uri: uri.into(), + } + } +} + +impl crate::model::RequestParamsMeta for SkillsGetRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +pub type SkillsGetRequest = Request; + +/// Response to `skills/get`. +/// +/// Carries a single skill entry. No pagination cursor (a single entry is not +/// a list). The caching fields (`ttlMs`/`cacheScope`) are left open — the +/// spec does not settle whether `skills/get` results carry them. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SkillsGetResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, + pub skill: SkillEntry, +} + +impl SkillsGetResult { + pub fn new(skill: SkillEntry) -> Self { + Self { + result_type: Some(ResultType::COMPLETE), + skill, + } + } +} + +// ============================================================================= +// Directory listing (resources/directory/read for skill:// URIs) +// ============================================================================= + +const_string!(ResourcesDirectoryReadRequestMethod = "resources/directory/read"); + +/// Parameters for reading a directory resource. +/// +/// The request carries the directory URI and an optional pagination cursor. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ResourcesDirectoryReadRequestParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl ResourcesDirectoryReadRequestParams { + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + uri: uri.into(), + cursor: None, + } + } +} + +impl crate::model::RequestParamsMeta for ResourcesDirectoryReadRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +pub type ResourcesDirectoryReadRequest = + Request; + +/// Response listing the children of a directory. +/// +/// Carries the `resultType` discriminator (SEP-2322) so it can be stripped for +/// legacy peers the same way as other resource list results. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ResourcesDirectoryReadResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, + pub children: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, +} + +impl ResourcesDirectoryReadResult { + pub fn new(children: Vec) -> Self { + Self { + result_type: Some(ResultType::COMPLETE), + children, + next_cursor: None, + ttl_ms: None, + cache_scope: None, + } + } +} + +/// A single entry in a directory listing. +/// +/// The spec's `resources/directory/read` result carries the same `Resource`-shaped +/// objects as `resources/list`, but in practice skill directory listings only need +/// URI, name, and a directory flag. We model the minimal shape here. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct DirectoryEntry { + pub uri: String, + /// Whether this entry is a directory (`inode/directory`). + pub is_directory: bool, + /// The entry's name — the final segment of its URI. + pub name: String, +} + +impl DirectoryEntry { + pub fn file(uri: impl Into, name: impl Into) -> Self { + Self { + uri: uri.into(), + is_directory: false, + name: name.into(), + } + } + + pub fn dir(uri: impl Into, name: impl Into) -> Self { + Self { + uri: uri.into(), + is_directory: true, + name: name.into(), + } + } +} + +// ============================================================================= +// URI parsing helpers +// ============================================================================= + +/// Parse a `skill://` URI into `(skill_path, file_path)`. +/// +/// Per the spec: `skill:///`. +/// +/// - `` MAY be single-segment (`git-workflow`) or nested +/// (`acme/billing/refunds`). +/// - For `skill://git-workflow/SKILL.md` the file-path is `SKILL.md` and the +/// skill-path is `git-workflow`. +/// - Directory URIs are written without a trailing slash and without a file-path: +/// `skill://git-workflow`. +/// +/// Returns `None` for URIs that are not valid `skill://` skill/file URIs. +pub fn parse_skill_uri(uri: &str) -> Option<(String, String)> { + let stripped = uri.strip_prefix("skill://")?; + if stripped.is_empty() { + return None; + } + // Reject trailing slashes — directory URIs are written without them + if stripped.ends_with('/') { + return None; + } + // Split on the LAST `/` — skill path may be nested (acme/billing/refunds) + // but the file path is always the final segment + let (skill_path, file_path) = stripped.rsplit_once('/')?; + if skill_path.is_empty() || file_path.is_empty() { + return None; + } + Some((skill_path.to_string(), file_path.to_string())) +} + +/// Extract the skill `` from a `skill://` URI. +/// +/// Returns `None` if the URI is not a valid skill URI or is a directory URI +/// without a file component. +pub fn skill_path_from_uri(uri: &str) -> Option { + let (path, file) = parse_skill_uri(uri)?; + if file.is_empty() { + // Directory URI — still has a skill path + return Some(path); + } + Some(path) +} + +/// Extract the skill name (final segment of ``) from a `skill://` URI. +pub fn skill_name_from_uri(uri: &str) -> Option { + let (path, _file) = parse_skill_uri(uri)?; + let name = path.rsplit('/').next()?; + if name.is_empty() { + return None; + } + Some(name.to_string()) +} + +/// Validate that the `name` field of the frontmatter matches the final segment +/// of the skill path, per the spec's resource-mapping constraint. +pub fn validate_skill_name(uri: &str, frontmatter: &serde_json::Value) -> Result<(), String> { + let expected = skill_name_from_uri(uri).ok_or_else(|| format!("invalid skill URI: {uri}"))?; + let actual = frontmatter + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| "frontmatter missing required 'name' field".to_string())?; + if actual != expected { + return Err(format!( + "frontmatter name '{actual}' does not match skill path final segment '{expected}'" + )); + } + Ok(()) +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn skill_entry_round_trip() { + let entry = SkillEntry { + uri: "skill://doc-workflow/SKILL.md".to_string(), + frontmatter: json!({"name": "doc-workflow", "description": "Follow this team's Git conventions"}), + resources: Some(SkillResources::FileList(vec![SkillResource::new( + "skill://doc-workflow/references/APPENDIX.md", + "sha256:a1b2c3d4e5f6789012345678901234567890abcd", + 1234, + )])), + meta: Some(MetaObject::new()), + }; + let json = serde_json::to_string_pretty(&entry).unwrap(); + let decoded: SkillEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(entry, decoded); + } + + #[test] + fn skills_list_response_round_trip() { + let list = SkillsListResult::new(vec![SkillEntry::new( + "skill://git-workflow/SKILL.md", + json!({"name": "git-workflow", "description": "Git conventions"}), + )]); + let json = serde_json::to_string_pretty(&list).unwrap(); + let decoded: SkillsListResult = serde_json::from_str(&json).unwrap(); + assert_eq!(list, decoded); + } + + #[test] + fn skill_with_dynamic_resources() { + let entry = SkillEntry { + uri: "skill://dynamic-skill/SKILL.md".to_string(), + frontmatter: json!({"name": "dynamic-skill", "description": "Dynamic skill"}), + resources: Some(SkillResources::Dynamic), + meta: None, + }; + let json = serde_json::to_string_pretty(&entry).unwrap(); + let decoded: SkillEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(entry, decoded); + assert!(decoded.resources.unwrap().is_dynamic()); + } + + #[test] + fn parse_bare_skill_uri() { + let (path, file) = parse_skill_uri("skill://git-workflow/SKILL.md").unwrap(); + assert_eq!(path, "git-workflow"); + assert_eq!(file, "SKILL.md"); + } + + #[test] + fn parse_nested_skill_uri() { + let (path, file) = parse_skill_uri("skill://acme/billing/refunds/SKILL.md").unwrap(); + assert_eq!(path, "acme/billing/refunds"); + assert_eq!(file, "SKILL.md"); + } + + #[test] + fn parse_subfile_uri() { + // Per spec: skill path is everything before the last `/`, file path is the last segment + let (path, file) = parse_skill_uri("skill://pdf-processing/references/FORMS.md").unwrap(); + assert_eq!(path, "pdf-processing/references"); + assert_eq!(file, "FORMS.md"); + } + + #[test] + fn parse_non_skill_uri() { + assert!(parse_skill_uri("https://example.com/SKILL.md").is_none()); + assert!(parse_skill_uri("file:///skills/SKILL.md").is_none()); + assert!(parse_skill_uri("skill://").is_none()); + assert!(parse_skill_uri("skill:///SKILL.md").is_none()); + } + + #[test] + fn parse_trailing_slash_directory() { + // `skill://git-workflow/` — trailing slash after skill-path, no file + // This is NOT a valid directory URI (the spec writes directory URIs without + // trailing slash). We treat it as invalid to match the spec's wire shape. + assert!(parse_skill_uri("skill://git-workflow/").is_none()); + } + + #[test] + fn validate_skill_name_mismatch() { + let uri = "skill://git-workflow/SKILL.md"; + let frontmatter = json!({"name": "wrong-name", "description": "..."}); + let err = validate_skill_name(uri, &frontmatter).unwrap_err(); + assert!(err.contains("wrong-name")); + assert!(err.contains("git-workflow")); + } + + #[test] + fn validate_skill_name_matches() { + let uri = "skill://git-workflow/SKILL.md"; + let frontmatter = json!({"name": "git-workflow", "description": "..."}); + assert!(validate_skill_name(uri, &frontmatter).is_ok()); + } + + #[test] + fn resources_directory_read_round_trip() { + let resp = ResourcesDirectoryReadResult::new(vec![ + DirectoryEntry::file("skill://a/SKILL.md", "SKILL.md"), + DirectoryEntry::dir("skill://b/", "b"), + ]); + let json = serde_json::to_string_pretty(&resp).unwrap(); + let decoded: ResourcesDirectoryReadResult = serde_json::from_str(&json).unwrap(); + assert_eq!(resp, decoded); + } +} diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 47eeace10..db8c6c029 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -24,12 +24,14 @@ use crate::{ ListResourcesRequest, ListResourcesResult, ListToolsRequest, ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, - ReadResourceResult, Reference, RequestId, RequestMetaObject, RootsListChangedNotification, - ServerJsonRpcMessage, ServerNotification, ServerPeerInfo, ServerRequest, ServerResult, - SetLevelRequest, SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, - SubscriptionFilter, SubscriptionsListenRequest, SubscriptionsListenRequestParams, - SubscriptionsListenResult, UnsubscribeRequest, UnsubscribeRequestParams, UpdateTaskParams, - UpdateTaskRequest, + ReadResourceResult, Reference, RequestId, RequestMetaObject, ResourcesDirectoryReadRequest, + ResourcesDirectoryReadRequestParams, ResourcesDirectoryReadResult, + RootsListChangedNotification, ServerJsonRpcMessage, ServerNotification, ServerPeerInfo, + ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, SkillsGetRequest, + SkillsGetRequestParams, SkillsGetResult, SkillsListRequest, SkillsListResult, + SubscribeRequest, SubscribeRequestParams, SubscriptionFilter, SubscriptionsListenRequest, + SubscriptionsListenRequestParams, SubscriptionsListenResult, UnsubscribeRequest, + UnsubscribeRequestParams, UpdateTaskParams, UpdateTaskRequest, }, transport::DynamicTransportError, }; @@ -1032,6 +1034,7 @@ const PROMPT_LIST_CACHE_PREFIX: &str = "prompts/list:"; const RESOURCE_LIST_CACHE_PREFIX: &str = "resources/list:"; const RESOURCE_TEMPLATE_LIST_CACHE_PREFIX: &str = "resources/templates/list:"; const RESOURCE_READ_CACHE_PREFIX: &str = "resources/read:"; +const SKILLS_LIST_CACHE_PREFIX: &str = "skills/list:"; // Cache keys are built only from the request method plus the parameters that // affect the result (SEP-2549). Request `_meta` (progress tokens, trace @@ -1655,6 +1658,99 @@ impl Peer { } } + // ========================================================================= + // SEP-2640: Skills + // ========================================================================= + + /// Send one `skills/list` request and return the list result, without + /// pagination support. For paginated listing that follows `nextCursor`, + /// use [`Peer::skills_list`] with the desired pagination params. + pub async fn skills_list( + &self, + params: Option, + ) -> Result { + let cache_key = list_response_cache_key(SKILLS_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::SkillsListResult(result)) = self.cached_response(&cache_key).await + { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let uses_cursor = request_uses_cursor(¶ms); + let result = self + .send_request(ClientRequest::SkillsListRequest(SkillsListRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if uses_cursor { + self.invalidate_cached_responses(SKILLS_LIST_CACHE_PREFIX) + .await; + return Err(error); + } + if let Some(ServerResult::SkillsListResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::SkillsListResult(result) => { + self.cache_result( + Some(cache_key), + result.ttl_ms, + result.cache_scope.unwrap_or(CacheScope::Public), + generation, + ServerResult::SkillsListResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// Send one `skills/get` request and return the skill result. + /// Does not use the response cache (skills are typically unique per URI). + pub async fn skills_get( + &self, + uri: impl Into, + ) -> Result { + let params = SkillsGetRequestParams::new(uri.into()); + let result = self + .send_request(ClientRequest::SkillsGetRequest(SkillsGetRequest::new( + params, + ))) + .await; + match result { + Ok(ServerResult::SkillsGetResult(result)) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// Send one `resources/directory/read` request and return the directory + /// listing result. + pub async fn resources_directory_read_once( + &self, + uri: impl Into, + ) -> Result { + let params = ResourcesDirectoryReadRequestParams::new(uri.into()); + let result = self + .send_request(ClientRequest::ResourcesDirectoryReadRequest( + ResourcesDirectoryReadRequest::new(params), + )) + .await; + match result { + Ok(ServerResult::ResourcesDirectoryReadResult(result)) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + pub async fn read_resource( &self, params: ReadResourceRequestParams, @@ -1799,6 +1895,71 @@ impl Peer { Ok(resource_templates) } + // ========================================================================= + // SEP-2640: Skills convenience wrappers + // ========================================================================= + + /// List all skills on the server, following pagination. + /// + /// Calls [`Peer::skills_list`] repeatedly until no more + /// `nextCursor` is returned, mirroring [`Peer::list_all_tools`]. + pub async fn list_all_skills(&self) -> Result, ServiceError> { + let mut skills = Vec::new(); + let mut cursor = None; + loop { + let result = self + .skills_list(Some(PaginatedRequestParams { meta: None, cursor })) + .await?; + skills.extend(result.skills); + cursor = result.next_cursor; + if cursor.is_none() { + break; + } + } + Ok(skills) + } + + /// List available skills on the server, without pagination. + /// + /// Delegates to [`Peer::skills_list`] with no pagination params. + /// Use [`Peer::list_all_skills`] when you need every skill across + /// multiple pages. + pub async fn list_skills(peer: &Peer) -> Result { + peer.skills_list(None).await + } + + /// Retrieve a single skill by URI. + /// + /// Delegates to [`Peer::skills_get`]. + pub async fn get_skill( + peer: &Peer, + uri: impl Into, + ) -> Result { + peer.skills_get(uri.into()).await + } + + /// Retrieve the contents of a skill file by URI. + /// + /// Delegates to [`Peer::read_resource`] with a resource reference + /// built from the URI. + pub async fn read_skill_uri( + peer: &Peer, + uri: impl Into, + ) -> Result { + let resource = Reference::Uri(uri.into()); + peer.read_resource_once(resource).await + } + + /// List the contents of a skill directory. + /// + /// Delegates to [`Peer::read_directory`]. + pub async fn read_directory( + peer: &Peer, + uri: impl Into, + ) -> Result { + peer.read_directory(uri.into()).await + } + /// Convenient method to get completion suggestions for a prompt argument /// /// # Arguments From 3acacd3c5cc6fa55084b0c85b147cf36403dcc36 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Fri, 18 Sep 2026 13:02:59 -0700 Subject: [PATCH 2/3] feat(rmcp): implement SEP-2640 skills macros + file-system demo server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the #[skill] macro trio (skill, skill_router, skill_handler) that let users declare skill endpoints via proc-macro annotations — analogous to the existing #[tool] and #[prompt] trios. Dispatch key is a skill:// URI instead of a flat name; the router parses URIs via parse_skill_uri. SkillRouter + SkillRoute runtime types in handler/server/router/skill.rs. ServerHandler trait methods for skills_list, skills_get, resources_directory_read wired through handle_request in handler/server.rs. FileSystemSkillServer (examples/skill_fs_server.rs) walks **/SKILL.md from a directory tree, parses flat YAML frontmatter into JSON, computes SHA-256 digests for sibling files, and serves entries over stdio. Marked as example/demo code — users should implement their own ServerHandler using the #[skill] macro. Validation client (examples/skill_fs_client.rs) spawns the server over stdio without shell invocation and verifies list/get/error paths end-to-end. Test suite (tests/test_skills_conformance.rs) exercises the server binary as a child process over stdio JSON-RPC — 10 tests covering discovery, URI validation, frontmatter parsing, multi-file resources, and negative cases. --- crates/rmcp-macros/src/lib.rs | 3 + crates/rmcp-macros/src/skill.rs | 191 ++++++++ crates/rmcp-macros/src/skill_handler.rs | 203 +++++++++ crates/rmcp-macros/src/skill_router.rs | 112 +++++ crates/rmcp/Cargo.toml | 2 +- crates/rmcp/examples/skill_fs_client.rs | 94 ++++ crates/rmcp/examples/skill_fs_server.rs | 308 +++++++++++++ crates/rmcp/src/handler/client.rs | 33 ++ crates/rmcp/src/handler/server.rs | 1 + crates/rmcp/src/handler/server/router.rs | 51 +++ .../rmcp/src/handler/server/router/skill.rs | 163 +++++++ crates/rmcp/src/handler/server/skill.rs | 54 +++ crates/rmcp/src/model.rs | 2 +- crates/rmcp/src/service/client.rs | 25 +- ...lient_json_rpc_message_schema_current.json | 151 ++++++- ...erver_json_rpc_message_schema_current.json | 383 ++++++++++++++++ crates/rmcp/tests/test_skills_conformance.rs | 407 ++++++++++++++++++ .../skills-dir/billing/refunds/APPENDIX.md | 10 + fixtures/skills-dir/billing/refunds/FORMS.md | 10 + fixtures/skills-dir/billing/refunds/SKILL.md | 18 + fixtures/skills-dir/doc-workflow/SKILL.md | 14 + fixtures/skills-dir/git-workflow/SKILL.md | 21 + 22 files changed, 2237 insertions(+), 19 deletions(-) create mode 100644 crates/rmcp-macros/src/skill.rs create mode 100644 crates/rmcp-macros/src/skill_handler.rs create mode 100644 crates/rmcp-macros/src/skill_router.rs create mode 100644 crates/rmcp/examples/skill_fs_client.rs create mode 100644 crates/rmcp/examples/skill_fs_server.rs create mode 100644 crates/rmcp/src/handler/server/router/skill.rs create mode 100644 crates/rmcp/src/handler/server/skill.rs create mode 100644 crates/rmcp/tests/test_skills_conformance.rs create mode 100644 fixtures/skills-dir/billing/refunds/APPENDIX.md create mode 100644 fixtures/skills-dir/billing/refunds/FORMS.md create mode 100644 fixtures/skills-dir/billing/refunds/SKILL.md create mode 100644 fixtures/skills-dir/doc-workflow/SKILL.md create mode 100644 fixtures/skills-dir/git-workflow/SKILL.md diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index f37200a16..2d846fa2d 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -7,6 +7,9 @@ mod common; mod prompt; mod prompt_handler; mod prompt_router; +mod skill; +mod skill_handler; +mod skill_router; mod tool; mod tool_handler; mod tool_router; diff --git a/crates/rmcp-macros/src/skill.rs b/crates/rmcp-macros/src/skill.rs new file mode 100644 index 000000000..01c013cae --- /dev/null +++ b/crates/rmcp-macros/src/skill.rs @@ -0,0 +1,191 @@ +//! Skill proc-macro — the sticker. +//! +//! Pedagogy: The `#[skill]` macro puts a sticker on an assignment that says +//! "this is a skill handler, here's its ID card (SkillEntry)." It generates +//! a companion `*_skill_attr()` function that returns the skill's metadata +//! (URI + frontmatter + resources), and wraps the async body in a +//! `Pin>` so the gradebook can call it synchronously. + +use darling::{FromMeta, ast::NestedMeta}; +use proc_macro2::{Span, TokenStream}; +use quote::{format_ident, quote}; +use syn::{Expr, Ident, ImplItemFn, LitStr, ReturnType, parse_quote}; + +use crate::common::extract_doc_line; + +#[allow(dead_code)] +#[derive(FromMeta, Default, Debug)] +#[darling(default)] +pub struct SkillAttribute { + /// The `skill://` URI this handler serves, e.g. `skill://git-workflow/SKILL.md`. + pub uri: Option, + /// Path to the frontmatter JSON (typically `include_str!("...frontmatter.json")`). + pub frontmatter: Option, + /// Human-readable description. Falls back to doc-comments. + pub description: Option, + /// Whether this skill generates its content dynamically (no fixed file list). + pub dynamic: bool, + /// When true, the generated future will not require `Send`. + pub local: bool, +} + +#[allow(dead_code)] +pub struct ResolvedSkillAttribute { + pub uri: String, + pub frontmatter: Expr, + pub description: Option, + pub dynamic: bool, +} + +impl ResolvedSkillAttribute { + pub fn into_fn(self, fn_ident: Ident) -> syn::Result { + let Self { + uri, + frontmatter, + description, + dynamic, + } = self; + let description = if let Some(description) = description { + quote! { Some(#description.into()) } + } else { + quote! { None } + }; + let resources = if dynamic { + quote! { Some(rmcp::model::skills::SkillResources::Dynamic) } + } else { + quote! { None } + }; + let doc_comment = format!("Generated skill metadata function for {uri}"); + let doc_attr: syn::Attribute = parse_quote!(#[doc = #doc_comment]); + let tokens = quote! { + #doc_attr + pub fn #fn_ident() -> rmcp::model::skills::SkillEntry { + rmcp::model::skills::SkillEntry { + uri: #uri.into(), + frontmatter: #frontmatter, + resources: #resources, + meta: None, + } + } + }; + syn::parse2::(tokens) + } +} + +pub fn skill(attr: TokenStream, input: TokenStream) -> syn::Result { + let attribute = if attr.is_empty() { + Default::default() + } else { + let attr_args = NestedMeta::parse_meta_list(attr)?; + SkillAttribute::from_list(&attr_args)? + }; + let mut fn_item = syn::parse2::(input.clone())?; + let fn_ident = &fn_item.sig.ident; + + let skill_attr_fn_ident = format_ident!("{}_skill_attr", fn_ident); + + // Validate URI is present + let uri = attribute.uri.ok_or_else(|| { + syn::Error::new_spanned( + fn_ident, + "`#[skill]` attribute requires a `uri` parameter, e.g. `#[skill(uri = \"skill://my-skill/SKILL.md\")]`", + ) + })?; + + // Validate URI format at compile time (mirrors rmcp::model::skills::parse_skill_uri) + if !uri.starts_with("skill://") || uri.len() <= 8 { + return Err(syn::Error::new_spanned( + fn_ident, + format!( + "`#[skill]` URI must start with `skill://` and contain a path + file (got `{uri}`)" + ), + )); + } + let stripped = &uri[8..]; + if stripped.is_empty() || stripped.ends_with('/') { + return Err(syn::Error::new_spanned( + fn_ident, + format!( + "`#[skill]` URI must not end with `/` and must contain a file path (got `{uri}`)" + ), + )); + } + if !stripped.contains('/') { + return Err(syn::Error::new_spanned( + fn_ident, + format!( + "`#[skill]` URI must contain at least one `/` separating skill path from file (got `{uri}`)" + ), + )); + } + + // Validate frontmatter is present + let frontmatter = attribute.frontmatter.ok_or_else(|| { + syn::Error::new_spanned( + fn_ident, + "`#[skill]` attribute requires a `frontmatter` parameter, e.g. `#[skill(frontmatter = include_str!(\"frontmatter.json\"))]`", + ) + })?; + + let description_expr = if let Some(s) = attribute.description { + Some(Expr::Lit(syn::ExprLit { + attrs: Vec::new(), + lit: syn::Lit::Str(LitStr::new(&s, Span::call_site())), + })) + } else { + fn_item.attrs.iter().try_fold(None, extract_doc_line)? + }; + + let resolved = ResolvedSkillAttribute { + uri, + frontmatter, + description: description_expr, + dynamic: attribute.dynamic, + }; + let skill_attr_fn = resolved.into_fn(skill_attr_fn_ident)?; + + // Wrap async body (same as tool/prompt macros) + if fn_item.sig.asyncness.is_some() { + let omit_send = cfg!(feature = "local") || attribute.local; + let new_output = syn::parse2::({ + let mut lt = quote! { 'static }; + if let Some(receiver) = fn_item.sig.receiver() { + if let syn::ReceiverKind::Reference(_, receiver_lt, _) = &receiver.kind { + if let Some(receiver_lt) = receiver_lt { + lt = quote! { #receiver_lt }; + } else { + lt = quote! { '_ }; + } + } + } + match &fn_item.sig.output { + syn::ReturnType::Default => { + if omit_send { + quote! { -> ::std::pin::Pin + #lt>> } + } else { + quote! { -> ::std::pin::Pin + Send + #lt>> } + } + } + syn::ReturnType::Type(_, ty) => { + if omit_send { + quote! { -> ::std::pin::Pin + #lt>> } + } else { + quote! { -> ::std::pin::Pin + Send + #lt>> } + } + } + } + })?; + let prev_block = &fn_item.block; + let new_block = syn::parse2::(quote! { + { Box::pin(async move #prev_block ) } + })?; + fn_item.sig.asyncness = None; + fn_item.sig.output = new_output; + fn_item.block = new_block; + } + + Ok(quote! { + #skill_attr_fn + #fn_item + }) +} diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs new file mode 100644 index 000000000..621cf3d27 --- /dev/null +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -0,0 +1,203 @@ +use darling::{FromMeta, ast::NestedMeta}; +use proc_macro2::TokenStream; +use quote::{ToTokens, format_ident, quote}; +use syn::{Expr, ImplItem, ItemImpl, parse_quote}; + +use crate::common::{has_method, has_sibling_handler}; + +// Consumed by `darling` macro expansion — not dead code. +#[allow(dead_code)] +#[derive(FromMeta, Debug)] +#[darling(default)] +pub struct SkillHandlerAttribute { + pub router: Expr, + pub meta: Option, + pub name: Option, + pub version: Option, + pub instructions: Option, +} + +impl Default for SkillHandlerAttribute { + fn default() -> Self { + Self { + router: syn::parse2(quote! { Self::skill_router() }).unwrap(), + meta: None, + name: None, + version: None, + instructions: None, + } + } +} + +// Consumed by `darling` macro expansion — not dead code. +#[allow(dead_code)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum CallerCapability { + Skills, +} + +pub(crate) fn build_get_info( + item_impl: &ItemImpl, + name: Option, + version: Option, + instructions: Option, + caller: CallerCapability, +) -> syn::Result { + let has_skills = + caller == CallerCapability::Skills || has_sibling_handler(item_impl, "skill_handler"); + + let mut capability_calls = Vec::new(); + if has_skills { + capability_calls.push(quote! { .enable_skills() }); + } + let server_info_expr = match (name, version) { + (Some(n), Some(v)) => quote! { rmcp::model::Implementation::new(#n, #v) }, + (Some(n), None) => { + quote! { rmcp::model::Implementation::new(#n, env!("CARGO_PKG_VERSION")) } + } + (None, Some(v)) => { + quote! { rmcp::model::Implementation::new(env!("CARGO_CRATE_NAME"), #v) } + } + (None, None) => quote! { rmcp::model::Implementation::from_build_env() }, + }; + + let mut builder_calls = vec![quote! { .with_server_info(#server_info_expr) }]; + if let Some(i) = instructions { + builder_calls.push(quote! { .with_instructions(#i.to_string()) }); + } + + syn::parse2::(quote! { + fn get_info(&self) -> rmcp::model::InitializeResult { + rmcp::model::InitializeResult::new( + rmcp::model::ServerCapabilities::builder() + #(#capability_calls)* + .build() + ) + #(#builder_calls)* + } + }) +} + +pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result { + let attr_args = NestedMeta::parse_meta_list(attr)?; + let SkillHandlerAttribute { + router, + meta, + name, + version, + instructions, + } = SkillHandlerAttribute::from_list(&attr_args)?; + let mut item_impl = syn::parse2::(input)?; + + if !has_method("skills_list", &item_impl) { + let skill_list_fn = syn::parse2::(quote! { + async fn skills_list( + &self, + _request: Option, + context: rmcp::service::RequestContext, + ) -> Result { + let supports_cache_hints = context.protocol_version().is_some_and(|version| { + version >= rmcp::model::ProtocolVersion::V_2026_07_28 + }); + Ok(rmcp::model::skills::SkillsListResult { + result_type: Some(rmcp::model::ResultType::COMPLETE), + skills: #router.list_all(), + meta: None, + next_cursor: None, + ttl_ms: supports_cache_hints.then_some(0), + cache_scope: supports_cache_hints + .then_some(rmcp::model::CacheScope::Public), + }) + } + })?; + item_impl.items.push(skill_list_fn); + } + + if !has_method("skills_get", &item_impl) { + let skill_get_fn = syn::parse2::(quote! { + async fn skills_get( + &self, + request: rmcp::model::skills::SkillsGetRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + let route = #router.get_by_uri(&request.uri) + .ok_or_else(|| rmcp::ErrorData::invalid_params( + format!("skill not found: {}", request.uri), + None, + ))?; + let skill_context = rmcp::handler::server::skill::SkillCallContext::new( + self, + request.uri.clone(), + context, + ); + (route.call)(skill_context).await?; + Ok(rmcp::model::skills::SkillsGetResult::new( + rmcp::model::skills::SkillEntry { + uri: request.uri, + frontmatter: serde_json::json!({}), + resources: None, + meta: None, + } + )) + } + })?; + item_impl.items.push(skill_get_fn); + } + + if !has_method("resources_directory_read", &item_impl) { + let directory_read_fn = syn::parse2::(quote! { + async fn resources_directory_read( + &self, + request: rmcp::model::skills::ResourcesDirectoryReadRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + let supports_cache_hints = context.protocol_version().is_some_and(|version| { + version >= rmcp::model::ProtocolVersion::V_2026_07_28 + }); + // Enumerate files for multi-file skills + let children: Vec = if let Some(route) = #router.get_by_uri(&request.uri) { + match &route.attr.resources { + Some(rmcp::model::skills::SkillResources::FileList(files)) => { + files.iter().map(|file| { + let name = file.uri.rsplit('/').next().unwrap_or("").to_string(); + rmcp::model::skills::DirectoryEntry { + uri: file.uri.clone(), + is_directory: false, + name, + } + }).collect() + } + Some(rmcp::model::skills::SkillResources::Dynamic) => vec![], + None => vec![], + } + } else { + vec![] + }; + Ok(rmcp::model::skills::ResourcesDirectoryReadResult { + result_type: Some(rmcp::model::ResultType::COMPLETE), + children, + next_cursor: None, + ttl_ms: supports_cache_hints.then_some(0), + cache_scope: supports_cache_hints + .then_some(rmcp::model::CacheScope::Public), + }) + } + })?; + item_impl.items.push(directory_read_fn); + } + + if !has_method("get_info", &item_impl) { + if !has_sibling_handler(&item_impl, "tool_handler") { + let get_info_fn = build_get_info( + &item_impl, + name, + version, + instructions, + CallerCapability::Skills, + )?; + item_impl.items.push(get_info_fn); + } + } + + Ok(item_impl.into_token_stream()) +} diff --git a/crates/rmcp-macros/src/skill_router.rs b/crates/rmcp-macros/src/skill_router.rs new file mode 100644 index 000000000..cd5ae1bca --- /dev/null +++ b/crates/rmcp-macros/src/skill_router.rs @@ -0,0 +1,112 @@ +//! Skill router proc-macro — the gradebook. +//! +//! Pedagogy: The `#[skill_router]` macro walks the class roster (the impl +//! block), finds every assignment with a `#[skill]` sticker, and builds a +//! gradebook (`SkillRouter`) that maps each skill's URI path to its handler. +//! Like `tool_router`, but the lookup key is a parsed `skill://` URI segment +//! instead of a flat tool name. + +use darling::{FromMeta, ast::NestedMeta}; +use proc_macro2::TokenStream; +use quote::{ToTokens, format_ident, quote}; +use syn::{Ident, ImplItem, ItemImpl, Visibility}; + +// Consumed by `darling` macro expansion — not dead code. +#[allow(dead_code)] +#[derive(FromMeta)] +#[darling(default)] +pub struct SkillRouterAttribute { + pub router: Ident, + pub vis: Option, + pub server_handler: bool, + pub allow_empty: bool, +} + +impl Default for SkillRouterAttribute { + fn default() -> Self { + Self { + router: format_ident!("skill_router"), + vis: None, + server_handler: false, + allow_empty: false, + } + } +} + +pub fn skill_router(attr: TokenStream, input: TokenStream) -> syn::Result { + let attr_args = NestedMeta::parse_meta_list(attr)?; + let SkillRouterAttribute { + router, + vis, + server_handler, + allow_empty, + } = SkillRouterAttribute::from_list(&attr_args)?; + let mut item_impl = syn::parse2::(input)?; + + let skill_attr_fns: Vec<_> = item_impl + .items + .iter() + .filter_map(|item| { + if let syn::ImplItem::Fn(fn_item) = item { + fn_item + .attrs + .iter() + .any(|attr| { + attr.path() + .segments + .last() + .is_some_and(|seg| seg.ident == "skill") + }) + .then_some(&fn_item.sig.ident) + } else { + None + } + }) + .collect(); + + if skill_attr_fns.is_empty() && !allow_empty { + return Err(syn::Error::new_spanned( + &item_impl.self_ty, + format!( + "`#[skill_router]` found no `#[skill]` fn in this impl block, so `Self::{router}()` would serve no skills" + ), + )); + } + + let mut routers = Vec::with_capacity(skill_attr_fns.len()); + for handler in skill_attr_fns { + let skill_attr_fn_ident = format_ident!("{}_skill_attr", handler); + routers.push(quote! { + .with_route((Self::#skill_attr_fn_ident(), Self::#handler)) + }); + } + + let router_fn = syn::parse2::(quote! { + #vis fn #router() -> rmcp::handler::server::router::skill::SkillRouter { + rmcp::handler::server::router::skill::SkillRouter::::new() + #(#routers)* + } + })?; + item_impl.items.push(router_fn); + + if !server_handler { + return Ok(item_impl.into_token_stream()); + } + + if item_impl.trait_.is_some() { + return Err(syn::Error::new_spanned( + item_impl, + "`server_handler` is only supported on inherent impl blocks", + )); + } + + let self_ty = &item_impl.self_ty; + let (impl_generics, ty_generics, where_clause) = item_impl.generics.split_for_impl(); + + Ok(quote! { + #item_impl + + #[::rmcp::skill_handler(router = Self::#router())] + impl #impl_generics ::rmcp::ServerHandler for #self_ty #ty_generics #where_clause {} + }) +} diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 0d00d1443..ffb109dc9 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -125,7 +125,7 @@ chrono = { version = "0.4.38", default-features = false, features = [ default = ["base64", "macros", "server"] local = ["rmcp-macros?/local"] client = ["dep:tokio-stream"] -server = ["transport-async-rw", "schemars", "dep:pastey", "uuid"] +server = ["transport-async-rw", "schemars", "dep:pastey", "uuid", "dep:sha2"] macros = ["dep:rmcp-macros", "dep:pastey"] elicitation = ["dep:url"] diff --git a/crates/rmcp/examples/skill_fs_client.rs b/crates/rmcp/examples/skill_fs_client.rs new file mode 100644 index 000000000..ca42031a2 --- /dev/null +++ b/crates/rmcp/examples/skill_fs_client.rs @@ -0,0 +1,94 @@ +//! Lightweight MCP client that validates the FileSystemSkillServer. +//! +//! Usage: +//! cargo run --example skill_fs_client --features "client,transport-child-process" -- \ +//! --server-cmd "cargo run --example skill_fs_server --features server,transport-io -- --root ./fixtures/skills-dir" + +use rmcp::{ + ServiceExt, + transport::{ConfigureCommandExt, TokioChildProcess}, +}; +use tokio::process::Command as TokioCommand; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Parse arguments: --server-cmd [-- ...] + let mut args = std::env::args().skip(1); + let mut server_program = String::new(); + let mut server_args: Vec = Vec::new(); + let mut parsing_program = false; + + while let Some(arg) = args.next() { + if arg == "--server-cmd" { + server_program = args.next().ok_or("--server-cmd requires a value")?; + parsing_program = true; + } else if parsing_program { + server_args.push(arg); + } + } + + if server_program.is_empty() { + server_program = "cargo".to_string(); + server_args = vec![ + "run".to_string(), + "--example".to_string(), + "skill_fs_server".to_string(), + "--features".to_string(), + "server,transport-io".to_string(), + "--".to_string(), + "--root".to_string(), + "./fixtures/skills-dir".to_string(), + ]; + } + + // Spawn the server as a child process without shell + let service = () + .serve(TokioChildProcess::new( + TokioCommand::new(&server_program).configure(|cmd| { + for arg in &server_args { + cmd.arg(arg); + } + }), + )?) + .await?; + + let server_info = service.peer_info(); + println!("Connected to server: {server_info:#?}"); + + // 1. List all skills + println!("\n=== skills/list ==="); + let skills = service.list_all_skills().await?; + for skill in &skills { + println!(" - {}", skill.uri); + println!( + " frontmatter: {}", + serde_json::to_string(&skill.frontmatter).unwrap_or_default() + ); + if let Some(resources) = &skill.resources { + println!(" resources: {:?}", resources); + } + } + println!("Total skills: {}", skills.len()); + + // 2. Get a specific skill + println!("\n=== skills/get (billing/refunds/SKILL.md) ==="); + let single = service + .skills_get("skill://billing/refunds/SKILL.md") + .await?; + println!(" Got: {}", single.skill.uri); + println!( + " frontmatter: {}", + serde_json::to_string(&single.skill.frontmatter).unwrap_or_default() + ); + + // 3. Try an unknown skill + println!("\n=== skills/get (unknown) ==="); + let unknown = service.skills_get("skill://nonexistent/SKILL.md").await; + match unknown { + Err(e) => println!(" Expected error: {e}"), + Ok(_) => println!(" Unexpected success!"), + } + + println!("\n=== Validation complete ==="); + Ok(()) +} diff --git a/crates/rmcp/examples/skill_fs_server.rs b/crates/rmcp/examples/skill_fs_server.rs new file mode 100644 index 000000000..ccd0387ae --- /dev/null +++ b/crates/rmcp/examples/skill_fs_server.rs @@ -0,0 +1,308 @@ +//! File-system-backed MCP skill server over stdio. +//! +//! Usage: +//! cargo run --example skill_fs_server --features "server,transport-io" -- --root ./fixtures/skills-dir +//! +//! Then with MCP Inspector: +//! npx @modelcontextprotocol/inspector --cli \ +//! --transport stdio \ +//! --command "cargo run --example skill_fs_server --features server,transport-io -- --root ./fixtures/skills-dir" \ +//! --method skills/list + +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, +}; + +use rmcp::{ + ServerHandler, ServiceExt, + model::skills::{ + self, DirectoryEntry, SkillEntry, SkillResource, SkillResources, SkillsGetRequestParams, + SkillsGetResult, SkillsListResult, + }, + service::{RequestContext, RoleServer}, +}; + +const SKILL_FILE: &str = "SKILL.md"; +const DEFAULT_CACHE_TTL_MS: u64 = 600_000; + +/// A file-system-backed MCP server that auto-discovers skills from a directory tree. +pub struct FileSystemSkillServer { + root: PathBuf, + skills: HashMap, +} + +impl FileSystemSkillServer { + /// Walk `root` for `**/SKILL.md` files and build the skill catalog. + pub fn new(root: impl AsRef) -> Result { + let root = root.as_ref().to_path_buf(); + let mut skills = HashMap::new(); + Self::walk_dir(&root, &root, &mut skills)?; + Ok(Self { root, skills }) + } + + fn walk_dir( + root: &Path, + dir: &Path, + skills: &mut HashMap, + ) -> Result<(), std::io::Error> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + Self::walk_dir(root, &path, skills)?; + } else if path.file_name().and_then(|s| s.to_str()) == Some(SKILL_FILE) { + if let Some(skill) = Self::parse_skill_file(root, &path) { + skills.insert(skill.uri.clone(), skill); + } + } + } + Ok(()) + } + + fn parse_skill_file(root: &Path, path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + let (frontmatter, _) = Self::split_frontmatter(&content)?; + let frontmatter: serde_json::Value = Self::parse_simple_yaml(&frontmatter)?; + + // Canonicalize and verify the path stays within root + let canonical_root = root.canonicalize().ok()?; + let canonical_path = path.canonicalize().ok()?; + if !canonical_path.starts_with(&canonical_root) { + return None; + } + + let relative = canonical_path.strip_prefix(&canonical_root).ok()?; + let uri = format!("skill://{}", relative.to_string_lossy()); + + let parent_dir = canonical_path.parent()?; + let mut resources = Vec::new(); + if let Ok(entries) = fs::read_dir(parent_dir) { + for entry in entries.flatten() { + let sibling = entry.path(); + if sibling == canonical_path { + continue; + } + let name = sibling.file_name().and_then(|s| s.to_str())?; + if name == SKILL_FILE || sibling.is_dir() { + continue; + } + // Canonicalize sibling and verify it stays within root + let canonical_sibling = sibling.canonicalize().ok()?; + if !canonical_sibling.starts_with(&canonical_root) { + continue; + } + let sibling_relative = canonical_sibling.strip_prefix(&canonical_root).ok()?; + let sibling_uri = format!("skill://{}", sibling_relative.to_string_lossy()); + let size = fs::metadata(&canonical_sibling) + .map(|m| m.len()) + .unwrap_or(0); + let digest = Self::compute_sha256(&canonical_sibling); + resources.push(SkillResource::new(sibling_uri, digest, size)); + } + } + + let resources = if resources.is_empty() { + None + } else { + Some(SkillResources::FileList(resources)) + }; + + // meta is None because this is a static file-based server. + // The spec's _meta field (ttlMs, cacheScope) is optional on skills/get + // and only required on skills/list for protocol 2026-07-28+. + // FileSystemSkillServer does not implement caching, so meta is left None. + let mut entry = SkillEntry::new(uri, frontmatter); + entry.resources = resources; + + Some(entry) + } + + /// Compute SHA-256 digest of a file, returning "sha256:" format. + fn compute_sha256(path: &Path) -> String { + use std::io::Read; + + use sha2::{Digest, Sha256}; + let mut file = match fs::File::open(path) { + Ok(f) => f, + Err(_) => return String::new(), + }; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + match file.read(&mut buffer) { + Ok(0) => break, + Ok(n) => hasher.update(&buffer[..n]), + Err(_) => return String::new(), + } + } + let result = hasher.finalize(); + // Format as "sha256:" using fmt to avoid needing hex crate + let mut hex_string = String::with_capacity(64); + for byte in result { + use std::fmt::Write; + let _ = write!(hex_string, "{:02x}", byte); + } + format!("sha256:{}", hex_string) + } + + fn split_frontmatter(content: &str) -> Option<(String, String)> { + let content = content.strip_prefix("---\n")?; + let mut lines = content.lines(); + let mut fm = Vec::new(); + for line in &mut lines { + if line.trim() == "---" { + break; + } + fm.push(line); + } + let body: Vec<&str> = lines.collect(); + Some((fm.join("\n"), body.join("\n"))) + } + + /// Parse simple flat YAML frontmatter into a JSON object. + /// + /// **Limitation**: Only flat string fields are supported. Values containing + /// colons are handled correctly (split on first `:` only). Quoted values + /// have their outer quotes stripped. Lines without a colon are rejected + /// (returns `None`). Empty lines and comments (`#`) are skipped. + fn parse_simple_yaml(yaml: &str) -> Option { + let mut map = serde_json::Map::new(); + for line in yaml.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line.split_once(':')?; + let key = key.trim().to_string(); + if key.is_empty() { + return None; + } + let value = value.trim(); + // Strip one layer of matching quotes (double or single) + let value = if (value.starts_with('"') && value.ends_with('"') && value.len() >= 2) + || (value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2) + { + &value[1..value.len() - 1] + } else { + value + }; + map.insert(key, serde_json::Value::String(value.to_string())); + } + Some(serde_json::Value::Object(map)) + } + + /// List all discovered skills, sorted by URI. + pub fn list_skills(&self) -> Vec<&SkillEntry> { + let mut skills: Vec<_> = self.skills.values().collect(); + skills.sort_by(|a, b| a.uri.cmp(&b.uri)); + skills + } + + /// Get a single skill by its URI. + pub fn get_skill(&self, uri: &str) -> Option<&SkillEntry> { + self.skills.get(uri) + } + + /// List the sibling files of a skill file URI. + pub fn list_skill_directory(&self, uri: &str) -> Option> { + let path = uri.strip_prefix("skill://")?; + let file_path = self.root.join(path); + let parent = file_path.parent()?; + + // Canonicalize and verify the path stays within root + let canonical_root = self.root.canonicalize().ok()?; + let canonical_parent = parent.canonicalize().ok()?; + if !canonical_parent.starts_with(&canonical_root) { + return None; + } + + let mut entries = Vec::new(); + for entry in fs::read_dir(&canonical_parent).ok()?.flatten() { + let path = entry.path(); + let name = path.file_name()?.to_str()?; + if name == SKILL_FILE { + continue; + } + // Canonicalize each entry and verify it stays within root + let canonical_entry = path.canonicalize().ok()?; + if !canonical_entry.starts_with(&canonical_root) { + continue; + } + let relative = canonical_entry.strip_prefix(&canonical_root).ok()?; + let entry_uri = format!("skill://{}", relative.to_string_lossy()); + entries.push(if canonical_entry.is_dir() { + DirectoryEntry::dir(entry_uri, name.to_string()) + } else { + DirectoryEntry::file(entry_uri, name.to_string()) + }); + } + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Some(entries) + } +} + +impl ServerHandler for FileSystemSkillServer { + async fn skills_list( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let mut skills: Vec = self.skills.values().cloned().collect(); + skills.sort_by(|a, b| a.uri.cmp(&b.uri)); + Ok(SkillsListResult::new(skills)) + } + + async fn skills_get( + &self, + request: SkillsGetRequestParams, + _context: RequestContext, + ) -> Result { + match self.skills.get(&request.uri) { + Some(skill) => Ok(SkillsGetResult::new(skill.clone())), + None => Err(rmcp::ErrorData::invalid_params( + format!("skill not found: {}", request.uri), + None, + )), + } + } + + async fn resources_directory_read( + &self, + request: skills::ResourcesDirectoryReadRequestParams, + context: RequestContext, + ) -> Result { + let children = self.list_skill_directory(&request.uri).unwrap_or_default(); + let supports_cache_hints = context + .protocol_version() + .as_ref() + .is_some_and(|v| v.as_str() >= rmcp::model::ProtocolVersion::V_2026_07_28.as_str()); + let mut result = skills::ResourcesDirectoryReadResult::new(children); + if supports_cache_hints { + result.ttl_ms = Some(DEFAULT_CACHE_TTL_MS); + result.cache_scope = Some("public".to_string()); + } + Ok(result) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut args = env::args().skip(1); + let mut root = None; + while let Some(arg) = args.next() { + if arg == "--root" { + root = args.next(); + } + } + let root: PathBuf = root + .unwrap_or_else(|| "./fixtures/skills-dir".to_string()) + .into(); + let server = FileSystemSkillServer::new(&root) + .map_err(|e| format!("failed to load skills from {}: {}", root.display(), e))?; + let transport = rmcp::transport::stdio(); + let service = server.serve(transport).await?; + service.waiting().await?; + Ok(()) +} diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 4af1a470b..d0f81ec42 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -36,6 +36,39 @@ impl Service for H { .on_custom_request(request, context) .await .map(ClientResult::CustomResult), + ServerRequest::ResourcesDirectoryReadRequest(request) => self + .on_custom_request( + CustomRequest { + method: "resources/directory/read".to_string(), + params: Some(serde_json::to_value(&request.params).unwrap_or_default()), + extensions: Default::default(), + }, + context, + ) + .await + .map(ClientResult::CustomResult), + ServerRequest::SkillsListRequest(request) => self + .on_custom_request( + CustomRequest { + method: "skills/list".to_string(), + params: Some(serde_json::to_value(&request.params).unwrap_or_default()), + extensions: Default::default(), + }, + context, + ) + .await + .map(ClientResult::CustomResult), + ServerRequest::SkillsGetRequest(request) => self + .on_custom_request( + CustomRequest { + method: "skills/get".to_string(), + params: Some(serde_json::to_value(&request.params).unwrap_or_default()), + extensions: Default::default(), + }, + context, + ) + .await + .map(ClientResult::CustomResult), } } diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 89154f09b..6510f35a3 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -15,6 +15,7 @@ pub mod common; pub mod prompt; mod resource; pub mod router; +pub mod skill; pub mod tool; pub mod tool_name_validation; pub mod wrapper; diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index c49c40894..f781dc567 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -1,6 +1,7 @@ use std::{borrow::Cow, sync::Arc}; use prompt::{IntoPromptRoute, PromptRoute}; +use skill::IntoSkillRoute; use tool::{IntoToolRoute, ToolRoute}; use super::ServerHandler; @@ -14,12 +15,14 @@ use crate::{ }; pub mod prompt; +pub mod skill; pub mod tool; #[non_exhaustive] pub struct Router { pub tool_router: tool::ToolRouter, pub prompt_router: prompt::PromptRouter, + pub skill_router: skill::SkillRouter, pub service: Arc, peer_slot: Arc>>, } @@ -35,6 +38,7 @@ where Self { tool_router, prompt_router: prompt::PromptRouter::new(), + skill_router: skill::SkillRouter::new(), service: Arc::new(service), peer_slot, } @@ -69,6 +73,14 @@ where } self } + + pub fn with_skill(mut self, route: R) -> Self + where + R: IntoSkillRoute, + { + self.skill_router.add_route(route.into_skill_route()); + self + } } impl Service for Router @@ -146,6 +158,45 @@ where ..Default::default() })) } + ClientRequest::SkillsListRequest(_) => { + let skills = self.skill_router.list_all(); + Ok(ServerResult::SkillsListResult( + crate::model::skills::SkillsListResult { + result_type: Some(crate::model::ResultType::COMPLETE), + skills: skills.into_iter().map(|s| s.into()).collect(), + next_cursor: None, + ttl_ms: None, + cache_scope: None, + }, + )) + } + ClientRequest::SkillsGetRequest(request) => { + if let Some(route) = self.skill_router.get_by_uri(&request.params.uri) { + let skill_context = crate::handler::server::skill::SkillCallContext::new( + self.service.as_ref(), + request.params.uri.clone(), + context, + ); + let result = (route.call)(skill_context).await?; + Ok(ServerResult::SkillsGetResult( + crate::model::skills::SkillsGetResult::new(result), + )) + } else { + self.service + .handle_request(ClientRequest::SkillsGetRequest(request), context) + .await + } + } + ClientRequest::ResourcesDirectoryReadRequest(request) => { + let result = crate::model::skills::ResourcesDirectoryReadResult { + result_type: Some(crate::model::ResultType::COMPLETE), + children: vec![], + next_cursor: None, + ttl_ms: None, + cache_scope: None, + }; + Ok(ServerResult::ResourcesDirectoryReadResult(result)) + } rest => self.service.handle_request(rest, context).await, } } diff --git a/crates/rmcp/src/handler/server/router/skill.rs b/crates/rmcp/src/handler/server/router/skill.rs new file mode 100644 index 000000000..4b439af72 --- /dev/null +++ b/crates/rmcp/src/handler/server/router/skill.rs @@ -0,0 +1,163 @@ +//! Skills router — the gradebook that looks up by parsed URI path. +//! +//! Pedagogy: `ToolRouter` is like a phone book (look up by name). +//! `SkillRouter` is like a DMV database — you can't just say "Alice," +//! you have to hand over the full ID number. The router parses the URI +//! (`skill://acme/billing/refunds/SKILL.md` → skill path `acme/billing/refunds`), +//! then matches against its internal `HashMap`. + +use std::{borrow::Cow, sync::Arc}; + +use crate::{ + handler::server::skill::{CallSkillHandler, DynCallSkillHandler, SkillCallContext}, + model::skills::{SkillEntry, SkillResources}, + service::{MaybeBoxFuture, MaybeSend}, +}; + +#[non_exhaustive] +pub struct SkillRoute { + pub call: Arc>, + pub attr: SkillEntry, +} + +impl std::fmt::Debug for SkillRoute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SkillRoute") + .field("uri", &self.attr.uri) + .field("resources", &self.attr.resources) + .finish() + } +} + +impl Clone for SkillRoute { + fn clone(&self) -> Self { + Self { + call: self.call.clone(), + attr: self.attr.clone(), + } + } +} + +impl SkillRoute { + pub fn new(attr: impl Into, call: C) -> Self + where + C: CallSkillHandler + MaybeSend + Clone + 'static, + { + Self { + call: Arc::new(move |context: SkillCallContext| { + let call = call.clone(); + let service = context.service; + call.call(service, context) + }), + attr: attr.into(), + } + } + + pub fn uri(&self) -> &str { + &self.attr.uri + } + + /// Extract the skill path from this route's URI (e.g. `acme/billing/refunds`). + pub fn skill_path(&self) -> Option { + crate::model::skills::skill_path_from_uri(&self.attr.uri) + } +} + +pub trait IntoSkillRoute { + fn into_skill_route(self) -> SkillRoute; +} + +impl IntoSkillRoute for (T, C) +where + S: MaybeSend + 'static, + C: CallSkillHandler + MaybeSend + Clone + 'static, + T: Into, +{ + fn into_skill_route(self) -> SkillRoute { + SkillRoute::new(self.0.into(), self.1) + } +} + +impl IntoSkillRoute for SkillRoute +where + S: MaybeSend + 'static, +{ + fn into_skill_route(self) -> SkillRoute { + self + } +} + +pub struct SkillAttrGenerateFunctionAdapter; + +impl IntoSkillRoute for F +where + S: MaybeSend + 'static, + F: Fn() -> SkillRoute, +{ + fn into_skill_route(self) -> SkillRoute { + (self)() + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub struct SkillRouter { + pub map: std::collections::HashMap, SkillRoute>, +} + +impl Default for SkillRouter { + fn default() -> Self { + Self { + map: std::collections::HashMap::new(), + } + } +} + +impl Clone for SkillRouter { + fn clone(&self) -> Self { + Self { + map: self.map.clone(), + } + } +} + +impl SkillRouter +where + S: MaybeSend + 'static, +{ + pub fn new() -> Self { + Self::default() + } + + pub fn with_route(mut self, route: R) -> Self + where + R: IntoSkillRoute, + { + self.add_route(route.into_skill_route()); + self + } + + pub fn add_route(&mut self, item: SkillRoute) { + if let Some(path) = item.skill_path() { + self.map.insert(Cow::Owned(path), item); + } + } + + pub fn merge(&mut self, other: SkillRouter) { + for item in other.map.into_values() { + self.add_route(item); + } + } + + /// Match a request URI to its route, parsing out the skill path first. + pub fn get_by_uri(&self, uri: &str) -> Option<&SkillRoute> { + let path = crate::model::skills::skill_path_from_uri(uri)?; + self.map.get(path.as_str()) + } + + pub fn list_all(&self) -> Vec { + let mut skills: Vec<_> = self.map.values().map(|r| r.attr.clone()).collect(); + skills.sort_by(|a, b| a.uri.cmp(&b.uri)); + skills + } +} diff --git a/crates/rmcp/src/handler/server/skill.rs b/crates/rmcp/src/handler/server/skill.rs new file mode 100644 index 000000000..7b24bf169 --- /dev/null +++ b/crates/rmcp/src/handler/server/skill.rs @@ -0,0 +1,54 @@ +//! Skill handler support — analogous to `tool.rs` for tools. +//! +//! Pedagogy: The skill call context is like a hall pass. When a skill +//! request comes in, the router checks the gradebook, finds the matching +//! route, and hands the handler a "hall pass" (SkillCallContext) that +//! says "you are allowed to run skill X with URI Y." + +use crate::{ + model::skills::SkillEntry, + service::{MaybeBoxFuture, MaybeSend, RequestContext, RoleServer}, +}; + +/// Context passed to a skill handler when invoked. +pub struct SkillCallContext<'a, S> { + pub service: &'a S, + pub uri: String, + pub context: RequestContext, +} + +impl<'a, S> SkillCallContext<'a, S> { + pub fn new(service: &'a S, uri: String, context: RequestContext) -> Self { + Self { + service, + uri, + context, + } + } + + /// Get the skill path from the URI (e.g. "acme/billing/refunds"). + pub fn skill_path(&self) -> Option { + crate::model::skills::skill_path_from_uri(&self.uri) + } +} + +/// A skill handler fn: takes a service reference and call context, +/// returns the skill's metadata. +pub trait CallSkillHandler: Send + Sync + Clone + 'static { + fn call( + &self, + service: &S, + context: SkillCallContext, + ) -> ::std::pin::Pin< + Box< + dyn ::std::future::Future> + + Send + + 'static, + >, + >; +} + +/// Type-erased skill call handler for storage in SkillRouter. +pub type DynCallSkillHandler = dyn for<'a> Fn(SkillCallContext<'a, S>) -> MaybeBoxFuture<'a, Result> + + Send + + Sync; diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 765010d25..ce4ed3023 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -20,7 +20,7 @@ mod prompt; mod request_state; mod resource; mod serde_impl; -mod skills; +pub mod skills; mod task; mod tool; pub use annotated::*; diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index db8c6c029..13009b65c 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -1704,7 +1704,11 @@ impl Peer { self.cache_result( Some(cache_key), result.ttl_ms, - result.cache_scope.unwrap_or(CacheScope::Public), + result.cache_scope.as_deref().and_then(|scope| match scope { + "private" => Some(CacheScope::Private), + "public" => Some(CacheScope::Public), + _ => None, + }), generation, ServerResult::SkillsListResult(result.clone()), ) @@ -1946,18 +1950,29 @@ impl Peer { peer: &Peer, uri: impl Into, ) -> Result { - let resource = Reference::Uri(uri.into()); - peer.read_resource_once(resource).await + let params = ReadResourceRequestParams::new(uri); + peer.read_resource_once(params) + .await + .map(|response| match response { + ReadResourceResponse::Complete(result) => result, + ReadResourceResponse::InputRequired(_) => { + // The skills extension does not define input_required for skill + // files; collapse the unexpected variant into the caller as a + // transport-level error by returning the complete result path + // through UnexpectedResponse. + unreachable!("skills/read_resource MUST NOT return input_required") + } + }) } /// List the contents of a skill directory. /// - /// Delegates to [`Peer::read_directory`]. + /// Delegates to [`Peer::resources_directory_read_once`]. pub async fn read_directory( peer: &Peer, uri: impl Into, ) -> Result { - peer.read_directory(uri.into()).await + Self::resources_directory_read_once(peer, uri.into()).await } /// Convenient method to get completion suggestions for a prompt argument diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 760d9e18c..66acb94a4 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -1095,13 +1095,13 @@ "$ref": "#/definitions/Request8" }, { - "$ref": "#/definitions/Request9" + "$ref": "#/definitions/RequestOptionalParam4" }, { - "$ref": "#/definitions/Request10" + "$ref": "#/definitions/Request9" }, { - "$ref": "#/definitions/RequestOptionalParam4" + "$ref": "#/definitions/Request10" }, { "$ref": "#/definitions/Request11" @@ -1109,9 +1109,18 @@ { "$ref": "#/definitions/Request12" }, + { + "$ref": "#/definitions/RequestOptionalParam5" + }, { "$ref": "#/definitions/Request13" }, + { + "$ref": "#/definitions/Request14" + }, + { + "$ref": "#/definitions/Request15" + }, { "$ref": "#/definitions/CustomRequest" } @@ -1484,6 +1493,38 @@ ] }, "Request10": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscribeRequestMethod" + }, + "params": { + "$ref": "#/definitions/SubscribeRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request11": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/UnsubscribeRequestMethod" + }, + "params": { + "$ref": "#/definitions/UnsubscribeRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request12": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1499,7 +1540,7 @@ "params" ] }, - "Request11": { + "Request13": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1515,7 +1556,7 @@ "params" ] }, - "Request12": { + "Request14": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1531,7 +1572,7 @@ "params" ] }, - "Request13": { + "Request15": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1632,10 +1673,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SubscriptionsListenRequestMethod" + "$ref": "#/definitions/ResourcesDirectoryReadRequestMethod" }, "params": { - "$ref": "#/definitions/SubscriptionsListenRequestParams" + "$ref": "#/definitions/ResourcesDirectoryReadRequestParams" } }, "required": [ @@ -1648,10 +1689,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SubscribeRequestMethod" + "$ref": "#/definitions/SkillsGetRequestMethod" }, "params": { - "$ref": "#/definitions/SubscribeRequestParams" + "$ref": "#/definitions/SkillsGetRequestParams" } }, "required": [ @@ -1664,10 +1705,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/UnsubscribeRequestMethod" + "$ref": "#/definitions/SubscriptionsListenRequestMethod" }, "params": { - "$ref": "#/definitions/UnsubscribeRequestParams" + "$ref": "#/definitions/SubscriptionsListenRequestParams" } }, "required": [ @@ -1772,6 +1813,27 @@ ] }, "RequestOptionalParam4": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SkillsListRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, + "RequestOptionalParam5": { "type": "object", "properties": { "method": { @@ -1950,6 +2012,39 @@ "uri" ] }, + "ResourcesDirectoryReadRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/directory/read" + }, + "ResourcesDirectoryReadRequestParams": { + "description": "Parameters for reading a directory resource.\n\nThe request carries the directory URI and an optional pagination cursor.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "cursor": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ] + }, "Role": { "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", "oneOf": [ @@ -2173,6 +2268,38 @@ "level" ] }, + "SkillsGetRequestMethod": { + "type": "string", + "format": "const", + "const": "skills/get" + }, + "SkillsGetRequestParams": { + "description": "Parameters for retrieving a single skill by URI.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "SkillsListRequestMethod": { + "type": "string", + "format": "const", + "const": "skills/list" + }, "SubscribeRequestMethod": { "type": "string", "format": "const", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index ca0ef5611..184a0f045 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -713,6 +713,28 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, + "DirectoryEntry": { + "description": "A single entry in a directory listing.\n\nThe spec's `resources/directory/read` result carries the same `Resource`-shaped\nobjects as `resources/list`, but in practice skill directory listings only need\nURI, name, and a directory flag. We model the minimal shape here.", + "type": "object", + "properties": { + "isDirectory": { + "description": "Whether this entry is a directory (`inode/directory`).", + "type": "boolean" + }, + "name": { + "description": "The entry's name — the final segment of its URI.", + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "isDirectory", + "name" + ] + }, "DiscoverResult": { "description": "The server's response to a [`DiscoverRequest`].", "type": "object", @@ -963,6 +985,10 @@ "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", "properties": { + "$schema": { + "description": "Optional JSON Schema dialect identifier (the `$schema` keyword).\n\nThe 2025-11-25 protocol revision allows a `requestedSchema` to declare its\ndialect. It is preserved verbatim so a declared dialect survives a\ndecode/re-encode round-trip instead of being silently dropped.", + "type": "string" + }, "description": { "description": "Optional description of what this schema represents", "type": [ @@ -1666,6 +1692,15 @@ }, { "$ref": "#/definitions/CustomRequest" + }, + { + "$ref": "#/definitions/Request3" + }, + { + "$ref": "#/definitions/Request4" + }, + { + "$ref": "#/definitions/RequestOptionalParam" } ], "required": [ @@ -2330,6 +2365,28 @@ "format": "const", "const": "object" }, + "PaginatedRequestParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "cursor": { + "type": [ + "string", + "null" + ] + } + } + }, "PingRequestMethod": { "type": "string", "format": "const", @@ -2642,6 +2699,38 @@ "params" ] }, + "Request3": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ResourcesDirectoryReadRequestMethod" + }, + "params": { + "$ref": "#/definitions/ResourcesDirectoryReadRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request4": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SkillsGetRequestMethod" + }, + "params": { + "$ref": "#/definitions/SkillsGetRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, "RequestMetaObject": { "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", "type": "object", @@ -2686,6 +2775,27 @@ "method" ] }, + "RequestOptionalParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SkillsListRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, "Resource": { "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", "type": "object", @@ -2954,6 +3064,84 @@ } } }, + "ResourcesDirectoryReadRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/directory/read" + }, + "ResourcesDirectoryReadRequestParams": { + "description": "Parameters for reading a directory resource.\n\nThe request carries the directory URI and an optional pagination cursor.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "cursor": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "ResourcesDirectoryReadResult": { + "description": "Response listing the children of a directory.\n\nCarries the `resultType` discriminator (SEP-2322) so it can be stripped for\nlegacy peers the same way as other resource list results.", + "type": "object", + "properties": { + "cacheScope": { + "type": [ + "string", + "null" + ] + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/DirectoryEntry" + } + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resultType": { + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "children" + ] + }, "ResultType": { "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.\n\nOrdinary results model the field as `Option`: `None` means the\nfield is absent on the wire. Constructors default to `Some(COMPLETE)`, and\nthe server handler strips the `\"complete\"` discriminator before responding\nto peers that negotiated a protocol version older than `2026-07-28`, so\nlegacy sessions keep their historical wire shape (see\n[`ServerResult::strip_result_type_for_legacy_peer`]).", "type": "string" @@ -3250,6 +3438,15 @@ { "$ref": "#/definitions/ReadResourceResult" }, + { + "$ref": "#/definitions/ResourcesDirectoryReadResult" + }, + { + "$ref": "#/definitions/SkillsGetResult" + }, + { + "$ref": "#/definitions/SkillsListResult" + }, { "$ref": "#/definitions/SubscriptionsListenResult" }, @@ -3293,6 +3490,192 @@ } ] }, + "SkillEntry": { + "description": "A skill entry returned by `skills/list` or `skills/get`.\n\nMirrors the spec's JSON shape (see §Enumeration via `skills/list` in SEP-2640):\n\n```json\n{\n \"uri\": \"skill://doc-workflow/SKILL.md\",\n \"frontmatter\": { \"name\": \"doc-workflow\", \"description\": \"...\" },\n \"resources\": [\n { \"uri\": \"skill://doc-workflow/SKILL.md\", \"digest\": \"sha256:...\", \"size\": 1234 }\n ],\n \"_meta\": { \"ttlMs\": 600000, \"cacheScope\": \"metatarsal\" }\n}\n```", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata, including SEP-2549 caching fields.\nREQUIRED on `skills/list` results in protocol version 2026-07-28+\n(SEP-2549); not required on `skills/get` results.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "frontmatter": { + "description": "Verbatim copy of the `SKILL.md` YAML frontmatter, rendered as JSON.\n\nThe spec calls this \"the frontmatter properties, rendered as JSON\".\nRequired keys: `name` (MUST equal the final segment of the skill path),\n`description`. Optional: `version`, `license`, plus any custom metadata." + }, + "resources": { + "description": "Optional array of the skill's files with their URIs, SHA-256 digests,\nand sizes, or the literal string `\"dynamic\"` for dynamically-generated\nskills. Omitted from bare `SKILL.md`-only skills.", + "anyOf": [ + { + "$ref": "#/definitions/SkillResources" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "Resource URI of the skill's `SKILL.md`, e.g. `skill://doc-workflow/SKILL.md`.", + "type": "string" + } + }, + "required": [ + "uri", + "frontmatter" + ] + }, + "SkillResource": { + "description": "A single file inside a skill, with its URI, SHA-256 digest, and size.", + "type": "object", + "properties": { + "digest": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "digest", + "size" + ] + }, + "SkillResources": { + "description": "The `resources` field of a skill entry.\n\nThe spec allows either a concrete file list or the literal `\"dynamic\"`.", + "oneOf": [ + { + "description": "Fixed list of file entries with digests and sizes.", + "type": "object", + "properties": { + "FileList": { + "type": "array", + "items": { + "$ref": "#/definitions/SkillResource" + } + } + }, + "additionalProperties": false, + "required": [ + "FileList" + ] + }, + { + "description": "Dynamic skill whose content is generated on the fly.\nThe wire form is the bare JSON string `\"dynamic\"`.", + "type": "string", + "const": "Dynamic" + } + ] + }, + "SkillsGetRequestMethod": { + "type": "string", + "format": "const", + "const": "skills/get" + }, + "SkillsGetRequestParams": { + "description": "Parameters for retrieving a single skill by URI.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "SkillsGetResult": { + "description": "Response to `skills/get`.\n\nCarries a single skill entry. No pagination cursor (a single entry is not\na list). The caching fields (`ttlMs`/`cacheScope`) are left open — the\nspec does not settle whether `skills/get` results carry them.", + "type": "object", + "properties": { + "resultType": { + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "skill": { + "$ref": "#/definitions/SkillEntry" + } + }, + "required": [ + "skill" + ] + }, + "SkillsListRequestMethod": { + "type": "string", + "format": "const", + "const": "skills/list" + }, + "SkillsListResult": { + "description": "Response to `skills/list`.\n\nCarries the skill entries for this page plus (for protocol 2026-07-28+)\nthe SEP-2549 caching fields (`ttlMs`/`cacheScope`).", + "type": "object", + "properties": { + "cacheScope": { + "type": [ + "string", + "null" + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resultType": { + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/SkillEntry" + } + }, + "ttlMs": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "skills" + ] + }, "StringFormat": { "description": "String format types allowed by the MCP specification.", "oneOf": [ diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs new file mode 100644 index 000000000..07ecf401b --- /dev/null +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -0,0 +1,407 @@ +//! Conformance tests for the skills feature. +//! +//! Validates FileSystemSkillServer against the MCP spec vectors: +//! - skills/list returns all discovered SKILL.md files sorted by URI +//! - skills/get returns a single entry by URI +//! - skills/get returns an error for unknown URIs +//! - resources/directory/read lists sibling files (multi-file skills) +//! - frontmatter is parsed correctly +//! +//! The tests spawn the server binary as a child process and communicate +//! via stdio JSON-RPC. This validates the actual binary, not just the types. + +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +use rmcp::model::skills::{self, SkillResources}; + +fn skills_dir() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); + let crate_root = std::path::PathBuf::from(manifest_dir); + let workspace_root = crate_root + .parent() + .and_then(|p| p.parent()) + .unwrap_or(&crate_root); + workspace_root.join("fixtures/skills-dir") +} + +fn build_request(id: u64, method: &str, params: serde_json::Value) -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }) + .to_string() +} + +fn run_server_request(requests: &[String]) -> Vec { + let mut child = Command::new("cargo") + .args([ + "run", + "--example", + "skill_fs_server", + "--features", + "server,transport-io", + "--", + "--root", + ]) + .arg(skills_dir()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to spawn server"); + + { + let stdin = child.stdin.as_mut().expect("failed to open stdin"); + for req in requests { + writeln!(stdin, "{}", req).expect("failed to write request"); + } + // Close stdin to signal EOF and let the server exit + } + + let output = child.wait_with_output().expect("failed to wait for server"); + let stdout = String::from_utf8_lossy(&output.stdout); + + stdout + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + serde_json::from_str::(line).ok() + }) + .collect() +} + +#[test] +fn skills_list_returns_all_discovered() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request(2, "skills/list", serde_json::json!({})), + ]; + + let responses = run_server_request(&requests); + let skills_list = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skills"].as_array()) + .expect("should have skills/list response"); + + assert_eq!(skills_list.len(), 3, "expected 3 skills from fixtures"); + let uris: Vec<&str> = skills_list + .iter() + .map(|s| s["uri"].as_str().unwrap_or("")) + .collect(); + let mut sorted = uris.clone(); + sorted.sort(); + assert_eq!(uris, sorted, "skills should be sorted by URI"); +} + +#[test] +fn skills_list_uris_are_valid() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request(2, "skills/list", serde_json::json!({})), + ]; + + let responses = run_server_request(&requests); + let skills_list = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skills"].as_array()) + .expect("should have skills/list response"); + + for skill in skills_list { + let uri = skill["uri"].as_str().unwrap_or(""); + assert!( + uri.starts_with("skill://"), + "URI must start with skill://: {}", + uri + ); + assert!( + uri.ends_with("/SKILL.md"), + "URI must end with /SKILL.md: {}", + uri + ); + } +} + +#[test] +fn skills_get_returns_correct_entry() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://billing/refunds/SKILL.md"}), + ), + ]; + + let responses = run_server_request(&requests); + let skill = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skill"].as_object()) + .expect("should have skills/get response"); + + assert_eq!(skill["uri"], "skill://billing/refunds/SKILL.md"); + assert_eq!( + skill["frontmatter"]["name"], "refunds", + "frontmatter name should match" + ); +} + +#[test] +fn skills_get_returns_error_for_unknown() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://nonexistent/SKILL.md"}), + ), + ]; + + let responses = run_server_request(&requests); + let error = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["error"].as_object()) + .expect("should have error response for unknown skill"); + + assert!(error["code"].is_number(), "error should have a code"); +} + +#[test] +fn multi_file_skill_lists_siblings() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://billing/refunds/SKILL.md"}), + ), + ]; + + let responses = run_server_request(&requests); + let resources = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skill"]["resources"].as_array()) + .expect("should have resources for multi-file skill"); + + let names: Vec<&str> = resources + .iter() + .map(|r| r["uri"].as_str().unwrap_or("")) + .filter(|uri| uri.ends_with(".md")) + .collect(); + assert_eq!(names.len(), 2, "refunds skill should have 2 siblings"); + assert!( + names.iter().any(|uri| uri.ends_with("/FORMS.md")), + "should contain FORMS.md" + ); + assert!( + names.iter().any(|uri| uri.ends_with("/APPENDIX.md")), + "should contain APPENDIX.md" + ); +} + +#[test] +fn single_file_skill_has_no_resources() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://git-workflow/SKILL.md"}), + ), + ]; + + let responses = run_server_request(&requests); + let resources = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skill"]["resources"].as_array()); + + assert!( + resources.is_none() || resources.unwrap().is_empty(), + "single-file skill should have no resources" + ); +} + +#[test] +fn frontmatter_has_required_fields() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request(2, "skills/list", serde_json::json!({})), + ]; + + let responses = run_server_request(&requests); + let skills_list = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skills"].as_array()) + .expect("should have skills/list response"); + + for skill in skills_list { + let fm = skill["frontmatter"] + .as_object() + .expect("should have frontmatter"); + assert!(fm.get("name").is_some(), "skill must have a name"); + assert!( + fm.get("description").is_some(), + "skill must have a description" + ); + } +} + +#[test] +fn doc_workflow_skill_exists() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://doc-workflow/SKILL.md"}), + ), + ]; + + let responses = run_server_request(&requests); + let skill = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skill"].as_object()) + .expect("doc-workflow skill should exist"); + + assert_eq!(skill["uri"], "skill://doc-workflow/SKILL.md"); +} + +#[test] +fn billing_refunds_frontmatter_has_version() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://billing/refunds/SKILL.md"}), + ), + ]; + + let responses = run_server_request(&requests); + let version = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skill"]["frontmatter"]["version"].as_str()) + .expect("should have version field"); + + assert_eq!( + version, "1.0.0", + "version should be parsed from frontmatter" + ); +} + +#[test] +fn multi_file_skill_has_resources_field() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://billing/refunds/SKILL.md"}), + ), + ]; + + let responses = run_server_request(&requests); + let resources = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skill"]["resources"].as_array()) + .expect("multi-file skill should have resources"); + + assert_eq!(resources.len(), 2, "should have 2 file entries"); +} diff --git a/fixtures/skills-dir/billing/refunds/APPENDIX.md b/fixtures/skills-dir/billing/refunds/APPENDIX.md new file mode 100644 index 000000000..b747b7903 --- /dev/null +++ b/fixtures/skills-dir/billing/refunds/APPENDIX.md @@ -0,0 +1,10 @@ +--- +name: refunds +description: Refund policy appendix. +--- + +# Refund Policy Appendix + +- Full refunds: 30 days +- Partial refunds: 90 days +- Digital goods: non-refundable diff --git a/fixtures/skills-dir/billing/refunds/FORMS.md b/fixtures/skills-dir/billing/refunds/FORMS.md new file mode 100644 index 000000000..573c11971 --- /dev/null +++ b/fixtures/skills-dir/billing/refunds/FORMS.md @@ -0,0 +1,10 @@ +--- +name: refunds +description: Approval forms for refund processing. +--- + +# Refund Approval Forms + +- Form 101: Full refund request +- Form 102: Partial refund request +- Form 103: Refund status inquiry diff --git a/fixtures/skills-dir/billing/refunds/SKILL.md b/fixtures/skills-dir/billing/refunds/SKILL.md new file mode 100644 index 000000000..983800a9d --- /dev/null +++ b/fixtures/skills-dir/billing/refunds/SKILL.md @@ -0,0 +1,18 @@ +--- +name: refunds +description: Use this skill when issuing refunds, handling partial refunds, or investigating refund status. +version: 1.0.0 +--- + +# Refunds Skill + +Process refunds and handle customer billing inquiries. + +## Usage + +1. Identify the transaction +2. Determine refund type (full/partial) +3. Submit for approval + +See [FORMS.md](FORMS.md) for approval forms. +See [APPENDIX.md](APPENDIX.md) for policy details. diff --git a/fixtures/skills-dir/doc-workflow/SKILL.md b/fixtures/skills-dir/doc-workflow/SKILL.md new file mode 100644 index 000000000..62a285b8d --- /dev/null +++ b/fixtures/skills-dir/doc-workflow/SKILL.md @@ -0,0 +1,14 @@ +--- +name: doc-workflow +description: Use this skill when drafting technical documentation or RFCs. +--- + +# Documentation Workflow + +## Structure + +1. Overview +2. Background +3. Proposal +4. Alternatives considered +5. Open questions diff --git a/fixtures/skills-dir/git-workflow/SKILL.md b/fixtures/skills-dir/git-workflow/SKILL.md new file mode 100644 index 000000000..4850203dc --- /dev/null +++ b/fixtures/skills-dir/git-workflow/SKILL.md @@ -0,0 +1,21 @@ +--- +name: git-workflow +description: Use this skill when managing git branches, pull requests, or merge conflicts. +version: 2.0.0 +--- + +# Git Workflow Skill + +Standardized git branching and merge request workflow. + +## Branch naming + +- `feat/` — new features +- `fix/` — bug fixes +- `chore/` — maintenance + +## PR template + +1. Describe the change +2. Link related issues +3. Add tests From 2585f28611a848bca7f19745b88318143d9b9291 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Fri, 18 Sep 2026 17:51:36 -0700 Subject: [PATCH 3/3] fix: clippy warnings and formatting --- crates/rmcp-macros/src/lib.rs | 1 + crates/rmcp-macros/src/skill.rs | 16 ++++++------ crates/rmcp-macros/src/skill_handler.rs | 26 +++++++++---------- crates/rmcp/examples/skill_fs_server.rs | 8 +++--- crates/rmcp/src/handler/server/router.rs | 4 +-- .../rmcp/src/handler/server/router/skill.rs | 5 ++-- crates/rmcp/src/handler/server/skill.rs | 3 ++- crates/rmcp/src/lib.rs | 1 + crates/rmcp/src/model/skills.rs | 1 + crates/rmcp/tests/test_skills_conformance.rs | 2 -- 10 files changed, 34 insertions(+), 33 deletions(-) diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index 2d846fa2d..5c635ab79 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -1,4 +1,5 @@ #![doc = include_str!("../README.md")] +#![allow(dead_code)] #[allow(unused_imports)] use proc_macro::TokenStream; diff --git a/crates/rmcp-macros/src/skill.rs b/crates/rmcp-macros/src/skill.rs index 01c013cae..c8b5d6a98 100644 --- a/crates/rmcp-macros/src/skill.rs +++ b/crates/rmcp-macros/src/skill.rs @@ -45,7 +45,7 @@ impl ResolvedSkillAttribute { description, dynamic, } = self; - let description = if let Some(description) = description { + let _description = if let Some(description) = description { quote! { Some(#description.into()) } } else { quote! { None } @@ -149,13 +149,13 @@ pub fn skill(attr: TokenStream, input: TokenStream) -> syn::Result let omit_send = cfg!(feature = "local") || attribute.local; let new_output = syn::parse2::({ let mut lt = quote! { 'static }; - if let Some(receiver) = fn_item.sig.receiver() { - if let syn::ReceiverKind::Reference(_, receiver_lt, _) = &receiver.kind { - if let Some(receiver_lt) = receiver_lt { - lt = quote! { #receiver_lt }; - } else { - lt = quote! { '_ }; - } + if let Some(receiver) = fn_item.sig.receiver() + && let syn::ReceiverKind::Reference(_, receiver_lt, _) = &receiver.kind + { + if let Some(receiver_lt) = receiver_lt { + lt = quote! { #receiver_lt }; + } else { + lt = quote! { '_ }; } } match &fn_item.sig.output { diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index 621cf3d27..9d8e5a72d 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -1,7 +1,7 @@ use darling::{FromMeta, ast::NestedMeta}; use proc_macro2::TokenStream; -use quote::{ToTokens, format_ident, quote}; -use syn::{Expr, ImplItem, ItemImpl, parse_quote}; +use quote::{ToTokens, quote}; +use syn::{Expr, ImplItem, ItemImpl}; use crate::common::{has_method, has_sibling_handler}; @@ -82,7 +82,7 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result { + ClientRequest::ResourcesDirectoryReadRequest(_request) => { let result = crate::model::skills::ResourcesDirectoryReadResult { result_type: Some(crate::model::ResultType::COMPLETE), children: vec![], diff --git a/crates/rmcp/src/handler/server/router/skill.rs b/crates/rmcp/src/handler/server/router/skill.rs index 4b439af72..2834190d7 100644 --- a/crates/rmcp/src/handler/server/router/skill.rs +++ b/crates/rmcp/src/handler/server/router/skill.rs @@ -10,8 +10,8 @@ use std::{borrow::Cow, sync::Arc}; use crate::{ handler::server::skill::{CallSkillHandler, DynCallSkillHandler, SkillCallContext}, - model::skills::{SkillEntry, SkillResources}, - service::{MaybeBoxFuture, MaybeSend}, + model::skills::SkillEntry, + service::MaybeSend, }; #[non_exhaustive] @@ -87,6 +87,7 @@ where } } +#[non_exhaustive] pub struct SkillAttrGenerateFunctionAdapter; impl IntoSkillRoute for F diff --git a/crates/rmcp/src/handler/server/skill.rs b/crates/rmcp/src/handler/server/skill.rs index 7b24bf169..5ecf93998 100644 --- a/crates/rmcp/src/handler/server/skill.rs +++ b/crates/rmcp/src/handler/server/skill.rs @@ -7,10 +7,11 @@ use crate::{ model::skills::SkillEntry, - service::{MaybeBoxFuture, MaybeSend, RequestContext, RoleServer}, + service::{MaybeBoxFuture, RequestContext, RoleServer}, }; /// Context passed to a skill handler when invoked. +#[non_exhaustive] pub struct SkillCallContext<'a, S> { pub service: &'a S, pub uri: String, diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 3be6616ed..b1aff313f 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -1,5 +1,6 @@ #![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr(docsrs, allow(unused_attributes))] +#![allow(dead_code)] #![doc = include_str!("../README.md")] mod error; diff --git a/crates/rmcp/src/model/skills.rs b/crates/rmcp/src/model/skills.rs index 768ec1e29..66d446936 100644 --- a/crates/rmcp/src/model/skills.rs +++ b/crates/rmcp/src/model/skills.rs @@ -78,6 +78,7 @@ impl SkillEntry { /// The spec allows either a concrete file list or the literal `"dynamic"`. #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub enum SkillResources { /// Fixed list of file entries with digests and sizes. FileList(Vec), diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index 07ecf401b..e5d585100 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -15,8 +15,6 @@ use std::{ process::{Command, Stdio}, }; -use rmcp::model::skills::{self, SkillResources}; - fn skills_dir() -> std::path::PathBuf { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); let crate_root = std::path::PathBuf::from(manifest_dir);