diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 23ccc9cca..55564f993 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -79,6 +79,9 @@ jobs: tasks-status-notifications tasks-required-task-error tasks-mrtr-composition + sep-2640-skills-enumeration + sep-2640-skills-manifest + sep-2640-skills-directory ) for scenario in "${scenarios[@]}"; do npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 5c4b5c80e..6ceddfedf 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -80,6 +80,134 @@ impl ConformanceServer { } } +// ─── SEP-2640 Skills extension fixtures ───────────────────────────────────── + +/// A file of a fixture skill, served through `resources/read`. +struct SkillFile { + uri: &'static str, + bytes: &'static [u8], +} + +/// Every file the fixture skills contain. `acme/billing/refunds` is nested in +/// `acme/billing`, so its files belong to both manifests. `live-metrics` is +/// dynamic and has no files beyond its `SKILL.md`. +const SKILL_FILES: &[SkillFile] = &[ + SkillFile { + uri: "skill://pdf-processing/SKILL.md", + bytes: b"---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\n---\n\n# PDF processing\n\nChoose the matching template from `templates/`.\n", + }, + SkillFile { + uri: "skill://pdf-processing/templates/invoice.md", + bytes: b"# Invoice\n\nBill-to, line items, total.\n", + }, + SkillFile { + uri: "skill://pdf-processing/templates/purchase-order.md", + bytes: b"# Purchase order\n\nVendor, items, approval.\n", + }, + SkillFile { + uri: "skill://pdf-processing/templates/regional/eu.md", + bytes: b"# EU invoice\n\nVAT number required.\n", + }, + SkillFile { + uri: "skill://acme/billing/SKILL.md", + bytes: b"---\nname: billing\ndescription: Issue and track customer invoices\n---\n\n# Billing\n\nFor refunds, see the nested `refunds` skill.\n", + }, + SkillFile { + uri: "skill://acme/billing/refunds/SKILL.md", + bytes: b"---\nname: refunds\ndescription: Process customer refund requests per company policy\nlicense: Apache-2.0\n---\n\n# Refunds\n\nFollow `examples/email.md` when writing to the customer.\n", + }, + SkillFile { + uri: "skill://acme/billing/refunds/examples/email.md", + bytes: b"Subject: Your refund has been processed\n", + }, + SkillFile { + uri: "skill://live-metrics/SKILL.md", + bytes: b"---\nname: live-metrics\ndescription: Metrics regenerated on every read\n---\n\n# Live metrics\n", + }, +]; + +fn skill_file(uri: &str) -> Option<&'static SkillFile> { + SKILL_FILES.iter().find(|file| file.uri == uri) +} + +/// The manifest of the skill rooted at `root`: every fixture file under it. +fn skill_manifest(root: &str) -> SkillResources { + let prefix = format!("{root}/"); + SkillResources::Files( + SKILL_FILES + .iter() + .filter(|file| file.uri.starts_with(&prefix)) + .map(|file| SkillResource::from_bytes(file.uri, file.bytes)) + .collect(), + ) +} + +fn skill_fixtures() -> Vec { + vec![ + Skill::new( + "skill://pdf-processing/SKILL.md", + SkillFrontmatter::new( + "pdf-processing", + "Extract, fill, and assemble PDF documents", + ), + skill_manifest("skill://pdf-processing"), + ), + Skill::new( + "skill://acme/billing/SKILL.md", + SkillFrontmatter::new("billing", "Issue and track customer invoices"), + skill_manifest("skill://acme/billing"), + ), + Skill::new( + "skill://acme/billing/refunds/SKILL.md", + SkillFrontmatter::new( + "refunds", + "Process customer refund requests per company policy", + ) + .with_field("license", "Apache-2.0"), + skill_manifest("skill://acme/billing/refunds"), + ), + Skill::new( + "skill://live-metrics/SKILL.md", + SkillFrontmatter::new("live-metrics", "Metrics regenerated on every read"), + SkillResources::Dynamic, + ), + ] +} + +/// The `SKILL.md` of each fixture skill as a resource, so `resources/list` +/// advertises the skills with the name and description of their frontmatter. +fn skill_resources() -> Vec { + skill_fixtures() + .iter() + .map(|skill| { + Resource::new(&skill.uri, &skill.frontmatter.name) + .with_description(&skill.frontmatter.description) + .with_mime_type("text/markdown") + }) + .collect() +} + +/// The direct children of the directory `dir`, or `None` when no fixture file +/// lives under it. Directory URIs carry no trailing slash. +fn directory_children(dir: &str) -> Option> { + let prefix = format!("{dir}/"); + let mut children: Vec = Vec::new(); + for file in SKILL_FILES { + let Some(relative) = file.uri.strip_prefix(&prefix) else { + continue; + }; + let child = match relative.split_once('/') { + None => Resource::new(file.uri, relative).with_mime_type("text/markdown"), + Some((subdir, _)) => Resource::new(format!("{prefix}{subdir}"), subdir) + .with_mime_type(DIRECTORY_MIME_TYPE), + }; + if !children.iter().any(|existing| existing.uri == child.uri) { + children.push(child); + } + } + (!children.is_empty()).then_some(children) +} + // ─── SEP-2663 Tasks extension fixtures ────────────────────────────────────── /// Fixture tools required by the Tasks extension conformance scenarios. @@ -716,6 +844,7 @@ impl ServerHandler for ConformanceServer { .enable_tool_list_changed() .enable_logging() .enable_tasks() + .enable_skills_with_directory_read() .build(), ) .with_server_info(Implementation::new("rust-conformance-server", "0.1.0")) @@ -764,6 +893,45 @@ impl ServerHandler for ConformanceServer { Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) } + async fn list_skills( + &self, + _request: Option, + _cx: RequestContext, + ) -> Result { + Ok(ListSkillsResult::with_all_items(skill_fixtures()) + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) + } + + async fn get_skill( + &self, + request: GetSkillRequestParams, + _cx: RequestContext, + ) -> Result { + let skill = skill_fixtures() + .into_iter() + .find(|skill| skill.uri == request.uri) + .ok_or_else(|| { + ErrorData::invalid_params(format!("No skill at {}", request.uri), None) + })?; + Ok(GetSkillResult::new(skill) + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) + } + + async fn read_resource_directory( + &self, + request: ReadResourceDirectoryRequestParams, + _cx: RequestContext, + ) -> Result { + let children = directory_children(&request.uri).ok_or_else(|| { + ErrorData::invalid_params(format!("Not a directory: {}", request.uri), None) + })?; + Ok(ReadResourceDirectoryResult::with_all_items(children) + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) + } + async fn update_task( &self, request: UpdateTaskParams, @@ -1452,15 +1620,17 @@ impl ServerHandler for ConformanceServer { _request: Option, _cx: RequestContext, ) -> Result { + let mut resources = vec![ + Resource::new("test://static-text", "Static Text Resource") + .with_description("A static text resource for testing") + .with_mime_type("text/plain"), + Resource::new("test://static-binary", "Static Binary Resource") + .with_description("A static binary/blob resource for testing") + .with_mime_type("image/png"), + ]; + resources.extend(skill_resources()); Ok(ListResourcesResult { - resources: vec![ - Resource::new("test://static-text", "Static Text Resource") - .with_description("A static text resource for testing") - .with_mime_type("text/plain"), - Resource::new("test://static-binary", "Static Binary Resource") - .with_description("A static binary/blob resource for testing") - .with_mime_type("image/png"), - ], + resources, ..Default::default() } .with_ttl_ms(CACHE_TTL_MS) @@ -1491,7 +1661,16 @@ impl ServerHandler for ConformanceServer { }, ])), _ => { - if uri.starts_with("test://template/") && uri.ends_with("/data") { + if let Some(file) = skill_file(uri) { + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("text/markdown".into()), + text: String::from_utf8_lossy(file.bytes).into_owned(), + meta: None, + }, + ])) + } else if uri.starts_with("test://template/") && uri.ends_with("/data") { let id = uri .strip_prefix("test://template/") .and_then(|s| s.strip_suffix("/data")) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 7da38d364..01b61cd26 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -69,9 +69,11 @@ schemars = { version = "1.0", optional = true, features = ["chrono04"] } # for image encoding base64 = { version = "0.23", optional = true } +# SHA-256 for SEP-2640 skill digests and SEP-2322 requestState sealing +sha2 = "0.11" + # for SEP-2322 requestState integrity sealing (opt-in via the `request-state` feature) hmac = { version = "0.13", optional = true } -sha2 = { version = "0.11", optional = true } zeroize = { version = "1", optional = true } # for HTTP client @@ -135,7 +137,7 @@ macros = ["dep:rmcp-macros", "dep:pastey"] elicitation = ["dep:url"] # SEP-2322 requestState integrity helper (HMAC-SHA256 seal/open codec) -request-state = ["dep:hmac", "dep:sha2", "dep:zeroize", "base64"] +request-state = ["dep:hmac", "dep:zeroize", "base64"] # reqwest http client __reqwest = ["dep:reqwest"] diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 4abe9ccb4..6354c0fa8 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -47,6 +47,30 @@ fn validate_tasks_capability( } } +/// SEP-2640: `skills/*` are unimplemented (`-32601`) unless the server +/// declares the skills extension; `resources/directory/read` also needs +/// `directoryRead`. +fn validate_skills_capability( + handler: &H, + directory_read: bool, +) -> Result<(), McpError> { + let capabilities = handler.get_info().capabilities; + let declared = if directory_read { + capabilities.supports_directory_read() + } else { + capabilities.supports_skills() + }; + if declared { + Ok(()) + } else { + Err(McpError::method_not_found::()) + } +} + +fn invalid_handler_result(error: impl std::fmt::Display) -> McpError { + McpError::internal_error(format!("handler returned an invalid result: {error}"), None) +} + impl Service for H { async fn handle_request( &self, @@ -242,6 +266,37 @@ impl Service for H { .await .map(ServerResult::task_ack) } + ClientRequest::ListSkillsRequest(request) => { + validate_skills_capability::(self, false)?; + let result = self.list_skills(request.params, context).await?; + validate_skills(&result.skills).map_err(invalid_handler_result)?; + Ok(ServerResult::ListSkillsResult(result)) + } + ClientRequest::GetSkillRequest(request) => { + validate_skills_capability::(self, false)?; + if skill_name_from_uri(&request.params.uri).is_none() { + return Err(McpError::invalid_params( + format!("{} is not the uri of a SKILL.md", request.params.uri), + None, + )); + } + let uri = request.params.uri.clone(); + let result = self.get_skill(request.params, context).await?; + if result.skill.uri != uri { + return Err(invalid_handler_result(format_args!( + "skill uri {} does not match the request {uri}", + result.skill.uri + ))); + } + result.skill.validate().map_err(invalid_handler_result)?; + Ok(ServerResult::GetSkillResult(result)) + } + ClientRequest::ReadResourceDirectoryRequest(request) => { + validate_skills_capability::(self, true)?; + self.read_resource_directory(request.params, context) + .await + .map(ServerResult::ListResourcesResult) + } }; let result = result.and_then(|mut result| { if matches!(result, ServerResult::InputRequiredResult(_)) && !is_2026_07_28_or_later { @@ -634,6 +689,41 @@ macro_rules! server_handler_methods { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } + + /// SEP-2640 `skills/list`: one page of the skills this server serves. + fn list_skills( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// SEP-2640 `skills/get`: the entry for one skill, or `-32602` when the + /// server does not serve a skill at that URI. + fn get_skill( + &self, + request: GetSkillRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// SEP-2640 `resources/directory/read`: the direct children of a + /// directory resource, or `-32602` when the URI is not a directory. + fn read_resource_directory( + &self, + request: ReadResourceDirectoryRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ + { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::< + ReadResourceDirectoryRequestMethod, + >())) + } }; } @@ -865,6 +955,33 @@ macro_rules! impl_server_handler_for_wrapper { ) -> impl Future> + MaybeSendFuture + '_ { (**self).cancel_task(request, context) } + + fn list_skills( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ + { + (**self).list_skills(request, context) + } + + fn get_skill( + &self, + request: GetSkillRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).get_skill(request, context) + } + + fn read_resource_directory( + &self, + request: ReadResourceDirectoryRequestParams, + context: RequestContext, + ) -> impl Future> + + MaybeSendFuture + + '_ { + (**self).read_resource_directory(request, context) + } } }; } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 9dd04564e..425a1e8fc 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -18,6 +18,7 @@ mod prompt; mod request_state; mod resource; mod serde_impl; +mod skill; mod task; mod tool; pub use annotated::*; @@ -33,6 +34,7 @@ pub use request_state::*; pub use resource::*; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; +pub use skill::*; pub use task::*; pub use tool::*; @@ -4738,6 +4740,111 @@ impl DerefMut for TaskStatusNotificationParams { pub type TaskStatusNotification = Notification; +// ============================================================================= +// SKILLS (SEP-2640 Skills extension: `io.modelcontextprotocol/skills`) +// ============================================================================= + +const_string!(ListSkillsRequestMethod = "skills/list"); +/// Request to list the skills a server serves. +pub type ListSkillsRequest = RequestOptionalParam; + +paginated_result!(ListSkillsResult { + skills: Vec +}); + +const_string!(GetSkillRequestMethod = "skills/get"); +/// Parameters for fetching one skill by the URI of its `SKILL.md`. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct GetSkillRequestParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// URI of the skill's `SKILL.md`. + pub uri: String, +} + +impl GetSkillRequestParams { + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + uri: uri.into(), + } + } + + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +impl RequestParamsMeta for GetSkillRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Request to fetch one skill. +pub type GetSkillRequest = Request; + +const_string!(ReadResourceDirectoryRequestMethod = "resources/directory/read"); +/// Parameters for listing the direct children of a directory resource. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ReadResourceDirectoryRequestParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + /// URI of the directory resource. + pub uri: String, +} + +impl ReadResourceDirectoryRequestParams { + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + cursor: None, + uri: uri.into(), + } + } + + pub fn with_cursor(mut self, cursor: impl Into) -> Self { + self.cursor = Some(cursor.into()); + self + } + + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +impl RequestParamsMeta for ReadResourceDirectoryRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Request to list the direct children of a directory resource. +pub type ReadResourceDirectoryRequest = + Request; + +/// Result of `resources/directory/read`. +/// +/// Same shape as `resources/list`, so the untagged [`ServerResult`] carries +/// it as [`ServerResult::ListResourcesResult`]. +pub type ReadResourceDirectoryResult = ListResourcesResult; + // ============================================================================= // MESSAGE TYPE UNIONS // ============================================================================= @@ -4812,6 +4919,9 @@ ts_union!( | GetTaskRequest | UpdateTaskRequest | CancelTaskRequest + | ListSkillsRequest + | GetSkillRequest + | ReadResourceDirectoryRequest | CustomRequest; ); @@ -4836,6 +4946,9 @@ impl ClientRequest { ClientRequest::GetTaskRequest(r) => r.method.as_str(), ClientRequest::UpdateTaskRequest(r) => r.method.as_str(), ClientRequest::CancelTaskRequest(r) => r.method.as_str(), + ClientRequest::ListSkillsRequest(r) => r.method.as_str(), + ClientRequest::GetSkillRequest(r) => r.method.as_str(), + ClientRequest::ReadResourceDirectoryRequest(r) => r.method.as_str(), ClientRequest::CustomRequest(r) => r.method.as_str(), } } @@ -4902,6 +5015,8 @@ ts_union!( | ReadResourceResult | SubscriptionsListenResult | ListToolsResult + | ListSkillsResult + | GetSkillResult | ElicitResult | CreateTaskResult | GetTaskResult @@ -4958,6 +5073,8 @@ impl ServerResult { ServerResult::ReadResourceResult(r) => &mut r.result_type, ServerResult::ListToolsResult(r) => &mut r.result_type, ServerResult::CallToolResult(r) => &mut r.result_type, + ServerResult::ListSkillsResult(r) => &mut r.result_type, + ServerResult::GetSkillResult(r) => &mut r.result_type, _ => return, }; result_type.take_if(|result_type| result_type.is_complete()); @@ -5005,6 +5122,10 @@ impl ServerResult { ServerResult::ReadResourceResult(r) => { (&r.result_type, &mut r.ttl_ms, &mut r.cache_scope) } + ServerResult::ListSkillsResult(r) => { + (&r.result_type, &mut r.ttl_ms, &mut r.cache_scope) + } + ServerResult::GetSkillResult(r) => (&r.result_type, &mut r.ttl_ms, &mut r.cache_scope), _ => return, }; // Only complete results are cacheable; the spec treats an absent diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index f014f569f..4efb7a534 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -5,8 +5,9 @@ use std::marker::PhantomData; #[cfg(any(feature = "server", feature = "macros"))] use pastey::paste; use serde::{Deserialize, Serialize}; +use serde_json::Value; -use super::JsonObject; +use super::{JsonObject, SkillsCapability}; pub type ExperimentalCapabilities = BTreeMap; /// MCP extension capabilities map. @@ -250,6 +251,24 @@ impl ServerCapabilities { .as_ref() .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) } + + /// The settings of the skills extension (SEP-2640), if declared. + /// + /// Settings that do not parse count as declared with the defaults. + pub fn skills(&self) -> Option { + let settings = self.extensions.as_ref()?.get(super::SKILLS_EXTENSION_ID)?; + Some(serde_json::from_value(Value::Object(settings.clone())).unwrap_or_default()) + } + + /// Returns `true` if the skills extension (SEP-2640) is declared. + pub fn supports_skills(&self) -> bool { + self.skills().is_some() + } + + /// Returns `true` if the server serves `resources/directory/read`. + pub fn supports_directory_read(&self) -> bool { + self.skills().is_some_and(|skills| skills.directory_read) + } } #[cfg(any(feature = "server", feature = "macros"))] @@ -432,6 +451,29 @@ impl ServerCapabilitiesBuilder { .insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new()); self } + + /// Declare the skills extension (SEP-2640) without directory reads. + /// + /// Skills ride on resources: the server must also enable `resources` and + /// serve the skill files through `resources/read`. + pub fn enable_skills(self) -> Self { + self.enable_skills_with(SkillsCapability::new()) + } + + /// Declare the skills extension (SEP-2640) with `resources/directory/read`. + pub fn enable_skills_with_directory_read(self) -> Self { + self.enable_skills_with(SkillsCapability::new().with_directory_read()) + } + + fn enable_skills_with(mut self, skills: SkillsCapability) -> Self { + let Ok(Value::Object(settings)) = serde_json::to_value(skills) else { + unreachable!("SkillsCapability serializes to an object") + }; + self.extensions + .get_or_insert_with(ExtensionCapabilities::new) + .insert(super::SKILLS_EXTENSION_ID.to_string(), settings); + self + } } #[cfg(any(feature = "server", feature = "macros"))] @@ -591,6 +633,62 @@ mod test { ); } + #[test] + fn enable_skills_declares_the_extension_with_empty_settings() { + let server = ServerCapabilities::builder().enable_skills().build(); + let json = serde_json::to_value(&server).unwrap(); + assert_eq!( + json["extensions"][crate::model::SKILLS_EXTENSION_ID], + serde_json::json!({}) + ); + } + + #[test] + fn enable_skills_with_directory_read_declares_the_setting() { + let server = ServerCapabilities::builder() + .enable_skills_with_directory_read() + .build(); + let json = serde_json::to_value(&server).unwrap(); + assert_eq!( + json["extensions"][crate::model::SKILLS_EXTENSION_ID], + serde_json::json!({ "directoryRead": true }) + ); + } + + #[test] + fn supports_skills_is_false_without_the_extension() { + assert!( + !ServerCapabilities::builder() + .enable_tasks() + .build() + .supports_skills() + ); + } + + #[test] + fn supports_directory_read_is_false_for_plain_skills() { + let server = ServerCapabilities::builder().enable_skills().build(); + assert!(server.supports_skills() && !server.supports_directory_read()); + } + + #[test] + fn skills_reads_directory_read_from_the_wire() { + let json = serde_json::json!({ + "extensions": { crate::model::SKILLS_EXTENSION_ID: { "directoryRead": true } } + }); + let server: ServerCapabilities = serde_json::from_value(json).unwrap(); + assert!(server.supports_directory_read()); + } + + #[test] + fn skills_treats_malformed_settings_as_declared_with_defaults() { + let json = serde_json::json!({ + "extensions": { crate::model::SKILLS_EXTENSION_ID: { "directoryRead": "yes" } } + }); + let server: ServerCapabilities = serde_json::from_value(json).unwrap(); + assert_eq!(server.skills(), Some(SkillsCapability::new())); + } + #[test] #[allow(deprecated)] fn test_client_extensions_capability() { diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 0a7121e4a..6a63784ec 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -191,6 +191,9 @@ variant_extension! { GetTaskRequest UpdateTaskRequest CancelTaskRequest + ListSkillsRequest + GetSkillRequest + ReadResourceDirectoryRequest } } diff --git a/crates/rmcp/src/model/skill.rs b/crates/rmcp/src/model/skill.rs new file mode 100644 index 000000000..5216bb054 --- /dev/null +++ b/crates/rmcp/src/model/skill.rs @@ -0,0 +1,728 @@ +//! Skill types for the MCP Skills extension (SEP-2640). +//! +//! A skill is a directory of files served over the Resources primitive: +//! `skills/list` and `skills/get` return the manifest of each skill, and +//! `resources/read` serves the file bytes. The request and result wiring +//! lives in the parent module next to the other methods. + +#[cfg(feature = "schemars")] +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +use super::{ + CacheScope, JsonObject, MetaObject, Resource, ResultType, deserialize_cache_scope, + deserialize_ttl_ms, +}; + +/// Extension identifier for the MCP Skills extension (SEP-2640). +pub const SKILLS_EXTENSION_ID: &str = "io.modelcontextprotocol/skills"; + +/// MIME type that marks a resource as a directory. +pub const DIRECTORY_MIME_TYPE: &str = "inode/directory"; + +impl Resource { + /// Returns `true` when the resource is a directory. + pub fn is_directory(&self) -> bool { + self.mime_type.as_deref() == Some(DIRECTORY_MIME_TYPE) + } +} + +/// Settings of the skills extension capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SkillsCapability { + /// The server serves `resources/directory/read`. + #[serde(default, skip_serializing_if = "is_false")] + pub directory_read: bool, +} + +impl SkillsCapability { + /// Capability without directory reads. + pub fn new() -> Self { + Self::default() + } + + /// Declare `resources/directory/read` support. + pub fn with_directory_read(mut self) -> Self { + self.directory_read = true; + self + } +} + +fn is_false(value: &bool) -> bool { + !value +} + +/// One file of a skill, with the digest and size of its bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SkillResource { + /// Resource URI of the file. + pub uri: String, + /// `sha256:` followed by 64 lowercase hex characters. + pub digest: String, + /// Length of the file in bytes. + 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, + } + } + + /// Describe `bytes` as the file at `uri`, with its digest and size computed. + pub fn from_bytes(uri: impl Into, bytes: &[u8]) -> Self { + Self::new(uri, sha256_digest(bytes), bytes.len() as u64) + } + + /// Check that `bytes` has the size and digest this entry promises. + /// + /// Compare the size first, so a wrong length is reported without hashing. + /// Whether the frontmatter in the bytes matches `skills/get` is the host's + /// check, not this one. + pub fn verify(&self, bytes: &[u8]) -> Result<(), SkillIntegrityError> { + let size = bytes.len() as u64; + if size != self.size { + return Err(SkillIntegrityError::Size { + uri: self.uri.clone(), + expected: self.size, + actual: size, + }); + } + let digest = sha256_digest(bytes); + if digest != self.digest { + return Err(SkillIntegrityError::Digest { + uri: self.uri.clone(), + expected: self.digest.clone(), + actual: digest, + }); + } + Ok(()) + } +} + +/// The `sha256:` digest of `bytes` in the form the spec requires. +pub fn sha256_digest(bytes: &[u8]) -> String { + use std::fmt::Write; + + use sha2::{Digest, Sha256}; + + let hash = Sha256::digest(bytes); + let mut digest = String::with_capacity(7 + 64); + digest.push_str("sha256:"); + for byte in hash { + write!(digest, "{byte:02x}").expect("writing to a String cannot fail"); + } + digest +} + +/// File bytes that do not match the manifest entry. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum SkillIntegrityError { + #[error("{uri} is {actual} bytes, the manifest says {expected}")] + Size { + uri: String, + expected: u64, + actual: u64, + }, + #[error("{uri} has digest {actual}, the manifest says {expected}")] + Digest { + uri: String, + expected: String, + actual: String, + }, +} + +/// The `SKILL.md` frontmatter, with every field the author wrote. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SkillFrontmatter { + pub name: String, + pub description: String, + /// Every frontmatter field other than `name` and `description`. + #[serde(flatten)] + pub extra: JsonObject, +} + +impl SkillFrontmatter { + pub fn new(name: impl Into, description: impl Into) -> Self { + Self { + name: name.into(), + description: description.into(), + extra: JsonObject::new(), + } + } + + /// Add a frontmatter field other than `name` and `description`. + pub fn with_field( + mut self, + key: impl Into, + value: impl Into, + ) -> Self { + self.extra.insert(key.into(), value.into()); + self + } +} + +/// The files of a skill: a full manifest, or `"dynamic"` when the content +/// changes and no stable digests exist. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum SkillResources { + Files(Vec), + Dynamic, +} + +const DYNAMIC: &str = "dynamic"; + +impl Serialize for SkillResources { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Files(files) => files.serialize(serializer), + Self::Dynamic => serializer.serialize_str(DYNAMIC), + } + } +} + +impl<'de> Deserialize<'de> for SkillResources { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Files(Vec), + Marker(String), + } + match Wire::deserialize(deserializer)? { + Wire::Files(files) => Ok(Self::Files(files)), + Wire::Marker(marker) if marker == DYNAMIC => Ok(Self::Dynamic), + Wire::Marker(marker) => Err(serde::de::Error::custom(format!( + "expected a list of skill resources or \"{DYNAMIC}\", found \"{marker}\"" + ))), + } + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for SkillResources { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("SkillResources") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let files = generator.subschema_for::>(); + schemars::json_schema!({ + "anyOf": [files, { "type": "string", "const": DYNAMIC }] + }) + } +} + +/// One skill as listed by `skills/list` or returned by `skills/get`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Skill { + /// Resource URI of the skill's `SKILL.md`. + pub uri: String, + pub frontmatter: SkillFrontmatter, + pub resources: SkillResources, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl Skill { + pub fn new( + uri: impl Into, + frontmatter: SkillFrontmatter, + resources: SkillResources, + ) -> Self { + Self { + uri: uri.into(), + frontmatter, + resources, + meta: None, + } + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } + + /// The skill name from the frontmatter. + pub fn name(&self) -> &str { + &self.frontmatter.name + } + + /// Check the rules the spec puts on a skill entry that can be checked + /// without reading any file. + pub fn validate(&self) -> Result<(), SkillValidationError> { + let Some(name) = skill_name_from_uri(&self.uri) else { + return Err(SkillValidationError::UriNotSkillMd(self.uri.clone())); + }; + if name != self.frontmatter.name { + return Err(SkillValidationError::NameMismatch { + uri: self.uri.clone(), + name: self.frontmatter.name.clone(), + }); + } + let SkillResources::Files(files) = &self.resources else { + return Ok(()); + }; + if !files.iter().any(|file| file.uri == self.uri) { + return Err(SkillValidationError::MissingSkillMd(self.uri.clone())); + } + let mut seen = std::collections::HashSet::with_capacity(files.len()); + for file in files { + if !seen.insert(file.uri.as_str()) { + return Err(SkillValidationError::DuplicateResource(file.uri.clone())); + } + if !is_sha256_digest(&file.digest) { + return Err(SkillValidationError::MalformedDigest { + uri: file.uri.clone(), + digest: file.digest.clone(), + }); + } + } + Ok(()) + } +} + +/// File name of the skill entry point. +pub const SKILL_MD: &str = "SKILL.md"; + +/// The skill name encoded in a `SKILL.md` URI: the path segment before `SKILL.md`. +pub fn skill_name_from_uri(uri: &str) -> Option<&str> { + let path = uri.strip_suffix(SKILL_MD)?.strip_suffix('/')?; + let name = path.rsplit('/').next()?; + (!name.is_empty()).then_some(name) +} + +/// Whether `digest` is `sha256:` followed by 64 lowercase hex characters. +pub fn is_sha256_digest(digest: &str) -> bool { + digest.strip_prefix("sha256:").is_some_and(|hex| { + hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +/// Check every skill of a listing, including that no URI appears twice. +pub fn validate_skills(skills: &[Skill]) -> Result<(), SkillValidationError> { + let mut seen = std::collections::HashSet::with_capacity(skills.len()); + for skill in skills { + skill.validate()?; + if !seen.insert(skill.uri.as_str()) { + return Err(SkillValidationError::DuplicateSkill(skill.uri.clone())); + } + } + Ok(()) +} + +/// A skill entry that breaks a structural rule of the spec. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum SkillValidationError { + #[error("skill uri {0} does not end in /SKILL.md")] + UriNotSkillMd(String), + #[error("skill uri {uri} does not end in the frontmatter name {name}")] + NameMismatch { uri: String, name: String }, + #[error("skill manifest {0} does not list its own SKILL.md")] + MissingSkillMd(String), + #[error("skill manifest lists {0} twice")] + DuplicateResource(String), + #[error("skill file {uri} has a malformed digest {digest}")] + MalformedDigest { uri: String, digest: String }, + #[error("skill {0} is listed twice")] + DuplicateSkill(String), +} + +/// Result of `skills/get`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct GetSkillResult { + /// Result type discriminator (SEP-2322); `None` on the wire means complete. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, + /// Freshness in milliseconds (SEP-2549); required on 2026-07-28, absent before. + #[serde( + default, + deserialize_with = "deserialize_ttl_ms", + skip_serializing_if = "Option::is_none" + )] + pub ttl_ms: Option, + /// Who may cache this result (SEP-2549); required on 2026-07-28, absent before. + #[serde( + default, + deserialize_with = "deserialize_cache_scope", + skip_serializing_if = "Option::is_none" + )] + pub cache_scope: Option, + pub skill: Skill, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl GetSkillResult { + pub fn new(skill: Skill) -> Self { + Self { + result_type: Some(ResultType::COMPLETE), + ttl_ms: None, + cache_scope: None, + skill, + meta: None, + } + } + + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = Some(ttl_ms); + self + } + + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + self.cache_scope = Some(cache_scope); + self + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn billing_skill() -> Skill { + Skill::new( + "skill://acme/billing/SKILL.md", + SkillFrontmatter::new("billing", "Issue invoices"), + SkillResources::Files(vec![SkillResource::new( + "skill://acme/billing/SKILL.md", + "sha256:9ff4e5c2e1a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f", + 1202, + )]), + ) + } + + mod skill_resources { + use super::*; + + #[test] + fn serializes_files_as_a_list() { + let files = SkillResources::Files(vec![SkillResource::new( + "skill://a/SKILL.md", + "sha256:00", + 1, + )]); + assert_eq!( + serde_json::to_value(&files).unwrap(), + json!([{ "uri": "skill://a/SKILL.md", "digest": "sha256:00", "size": 1 }]) + ); + } + + #[test] + fn serializes_dynamic_as_the_marker_string() { + assert_eq!( + serde_json::to_value(SkillResources::Dynamic).unwrap(), + json!("dynamic") + ); + } + + #[test] + fn deserializes_the_marker_string_as_dynamic() { + let resources: SkillResources = serde_json::from_value(json!("dynamic")).unwrap(); + assert_eq!(resources, SkillResources::Dynamic); + } + + #[test] + fn rejects_any_other_string() { + let error = serde_json::from_value::(json!("static")).unwrap_err(); + assert!(error.to_string().contains("found \"static\""), "{error}"); + } + + #[test] + fn deserializes_an_empty_list_as_files() { + let resources: SkillResources = serde_json::from_value(json!([])).unwrap(); + assert_eq!(resources, SkillResources::Files(vec![])); + } + } + + mod skill_frontmatter { + use super::*; + + #[test] + fn keeps_unknown_fields_on_round_trip() { + let wire = json!({ "name": "billing", "description": "Issue invoices", "license": "MIT", "allowed-tools": ["Read"] }); + let frontmatter: SkillFrontmatter = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(&frontmatter).unwrap(), wire); + } + + #[test] + fn requires_name() { + let error = serde_json::from_value::(json!({ "description": "x" })) + .unwrap_err(); + assert!(error.to_string().contains("name"), "{error}"); + } + } + + mod skill { + use super::*; + + #[test] + fn serializes_meta_under_underscore_meta() { + let skill = billing_skill().with_meta(MetaObject::new()); + let value = serde_json::to_value(skill).unwrap(); + assert!(value.get("_meta").is_some(), "{value}"); + } + + #[test] + fn name_comes_from_the_frontmatter() { + assert_eq!(billing_skill().name(), "billing"); + } + } + + /// The `pdf-processing` SKILL.md from the spec, whose digest and size the + /// spec's example manifest lists. + const SPEC_SKILL_MD: &[u8] = b"---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\n---\n\n# PDF processing\n\nChoose the matching template from `templates/`.\n"; + const SPEC_SKILL_MD_DIGEST: &str = + "sha256:99b737495721155ece826d57521e2d66141ebdc1344a400487481ea2642ab19e"; + + mod sha256_digest { + use super::*; + + #[test] + fn matches_the_digest_in_the_spec_example() { + assert_eq!(sha256_digest(SPEC_SKILL_MD), SPEC_SKILL_MD_DIGEST); + } + } + + mod skill_resource_verify { + use super::*; + + fn spec_entry() -> SkillResource { + SkillResource::new("skill://pdf-processing/SKILL.md", SPEC_SKILL_MD_DIGEST, 151) + } + + #[test] + fn accepts_the_bytes_the_manifest_describes() { + assert_eq!(spec_entry().verify(SPEC_SKILL_MD), Ok(())); + } + + #[test] + fn reports_a_size_mismatch_before_hashing() { + let error = spec_entry().verify(b"short").unwrap_err(); + assert!( + matches!(error, SkillIntegrityError::Size { actual: 5, .. }), + "{error}" + ); + } + + #[test] + fn reports_a_digest_mismatch_for_same_sized_bytes() { + let mut tampered = SPEC_SKILL_MD.to_vec(); + tampered[0] = b'+'; + let error = spec_entry().verify(&tampered).unwrap_err(); + assert!( + matches!(error, SkillIntegrityError::Digest { .. }), + "{error}" + ); + } + + #[test] + fn from_bytes_produces_an_entry_that_verifies() { + let entry = SkillResource::from_bytes("skill://pdf-processing/SKILL.md", SPEC_SKILL_MD); + assert_eq!(entry, spec_entry()); + } + } + + mod skill_name_from_uri { + use super::*; + + #[test] + fn returns_the_segment_before_skill_md() { + assert_eq!( + skill_name_from_uri("skill://acme/billing/SKILL.md"), + Some("billing") + ); + } + + #[test] + fn returns_none_without_skill_md() { + assert_eq!(skill_name_from_uri("skill://acme/billing/README.md"), None); + } + + #[test] + fn returns_none_for_skill_md_at_the_root() { + assert_eq!(skill_name_from_uri("skill:///SKILL.md"), None); + } + } + + mod is_sha256_digest { + use super::*; + + #[test] + fn accepts_sixty_four_lowercase_hex_characters() { + assert!(is_sha256_digest(&format!("sha256:{}", "a".repeat(64)))); + } + + #[test] + fn rejects_uppercase_hex() { + assert!(!is_sha256_digest(&format!("sha256:{}", "A".repeat(64)))); + } + + #[test] + fn rejects_a_short_digest() { + assert!(!is_sha256_digest("sha256:abc")); + } + + #[test] + fn rejects_a_missing_prefix() { + assert!(!is_sha256_digest(&"a".repeat(64))); + } + } + + mod skill_validate { + use super::*; + + #[test] + fn accepts_a_consistent_entry() { + assert_eq!(billing_skill().validate(), Ok(())); + } + + #[test] + fn accepts_a_dynamic_entry_without_files() { + let skill = Skill::new( + "skill://acme/billing/SKILL.md", + SkillFrontmatter::new("billing", "Issue invoices"), + SkillResources::Dynamic, + ); + assert_eq!(skill.validate(), Ok(())); + } + + #[test] + fn rejects_a_name_that_differs_from_the_uri() { + let mut skill = billing_skill(); + skill.frontmatter.name = "invoices".into(); + assert!(matches!( + skill.validate(), + Err(SkillValidationError::NameMismatch { .. }) + )); + } + + #[test] + fn rejects_a_manifest_without_skill_md() { + let mut skill = billing_skill(); + skill.resources = SkillResources::Files(vec![]); + assert!(matches!( + skill.validate(), + Err(SkillValidationError::MissingSkillMd(_)) + )); + } + + #[test] + fn rejects_a_duplicate_file() { + let mut skill = billing_skill(); + let SkillResources::Files(files) = &mut skill.resources else { + unreachable!() + }; + files.push(files[0].clone()); + assert!(matches!( + skill.validate(), + Err(SkillValidationError::DuplicateResource(_)) + )); + } + + #[test] + fn rejects_a_malformed_digest() { + let mut skill = billing_skill(); + let SkillResources::Files(files) = &mut skill.resources else { + unreachable!() + }; + files[0].digest = "md5:00".into(); + assert!(matches!( + skill.validate(), + Err(SkillValidationError::MalformedDigest { .. }) + )); + } + } + + mod validate_skills { + use super::*; + + #[test] + fn rejects_the_same_skill_listed_twice() { + let skills = [billing_skill(), billing_skill()]; + assert!(matches!( + validate_skills(&skills), + Err(SkillValidationError::DuplicateSkill(_)) + )); + } + } + + mod get_skill_result { + use super::*; + + #[test] + fn new_marks_the_result_complete() { + let value = serde_json::to_value(GetSkillResult::new(billing_skill())).unwrap(); + assert_eq!(value["resultType"], json!("complete")); + } + + #[test] + fn deserializes_without_cache_hints() { + let wire = json!({ "skill": serde_json::to_value(billing_skill()).unwrap() }); + let result: GetSkillResult = serde_json::from_value(wire).unwrap(); + assert_eq!((result.ttl_ms, result.cache_scope), (None, None)); + } + } + + mod resource_is_directory { + use super::*; + + #[test] + fn is_true_for_the_directory_mime_type() { + let dir = Resource::new("skill://acme/billing", "billing") + .with_mime_type(DIRECTORY_MIME_TYPE); + assert!(dir.is_directory()); + } + + #[test] + fn is_false_without_a_mime_type() { + assert!(!Resource::new("skill://acme/billing/SKILL.md", "SKILL.md").is_directory()); + } + } + + mod skills_capability { + use super::*; + + #[test] + fn serializes_to_an_empty_object_by_default() { + assert_eq!( + serde_json::to_value(SkillsCapability::new()).unwrap(), + json!({}) + ); + } + + #[test] + fn serializes_directory_read_when_set() { + let value = + serde_json::to_value(SkillsCapability::new().with_directory_read()).unwrap(); + assert_eq!(value, json!({ "directoryRead": true })); + } + } +} diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 5f3213d55..d094ac91b 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -17,19 +17,22 @@ use crate::{ ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, DiscoverRequest, DiscoverRequestParams, DiscoverResult, ErrorData, GetExtensions, GetMeta, GetPromptRequest, - GetPromptRequestParams, GetPromptResponse, GetPromptResult, GetTaskParams, GetTaskRequest, - GetTaskResult, InitializeRequest, InitializeRequestParams, InitializedNotification, - InputRequest, InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, + GetPromptRequestParams, GetPromptResponse, GetPromptResult, GetSkillRequest, + GetSkillRequestParams, GetSkillResult, GetTaskParams, GetTaskRequest, GetTaskResult, + InitializeRequest, InitializeRequestParams, InitializedNotification, InputRequest, + InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, - 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, + ListResourcesRequest, ListResourcesResult, ListSkillsRequest, ListSkillsResult, + ListToolsRequest, ListToolsResult, NumberOrString, PaginatedRequestParams, + ProgressNotification, ProgressNotificationParam, ProtocolVersion, + ReadResourceDirectoryRequest, ReadResourceDirectoryRequestMethod, + ReadResourceDirectoryRequestParams, ReadResourceDirectoryResult, 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, }, transport::DynamicTransportError, }; @@ -1530,6 +1533,73 @@ impl Peer { } } + /// SEP-2640 `skills/list`: one page of the skills the server serves. + /// + /// The result carries the server's cache hints but is not stored in the + /// client response cache. A 2026-07-28 server that omits the hints is + /// accepted, as for `list_tools`. + pub async fn list_skills( + &self, + params: Option, + ) -> Result { + let result = self + .send_request_cancel_on_drop(ClientRequest::ListSkillsRequest(ListSkillsRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::ListSkillsResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2640 `skills/get`: the entry for one skill. A server that does + /// not serve the URI answers with `-32602`, passed through unchanged. + pub async fn get_skill( + &self, + params: GetSkillRequestParams, + ) -> Result { + let result = self + .send_request_cancel_on_drop(ClientRequest::GetSkillRequest(GetSkillRequest::new( + params, + ))) + .await?; + match result { + ServerResult::GetSkillResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2640 `resources/directory/read`: the direct children of a + /// directory resource. + /// + /// Nothing is sent when the server did not declare `directoryRead`; the + /// call fails with `-32601` instead. + pub async fn read_resource_directory( + &self, + params: ReadResourceDirectoryRequestParams, + ) -> Result { + let declared = self + .peer_info() + .is_some_and(|info| info.capabilities.supports_directory_read()); + if !declared { + return Err(ServiceError::McpError(McpError::method_not_found::< + ReadResourceDirectoryRequestMethod, + >())); + } + let result = self + .send_request_cancel_on_drop(ClientRequest::ReadResourceDirectoryRequest( + ReadResourceDirectoryRequest::new(params), + )) + .await?; + match result { + ServerResult::ListResourcesResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + /// Send one `prompts/get` request and return either a final result or an MRTR /// `InputRequiredResult` without driving any follow-up rounds. pub async fn get_prompt_once( @@ -1906,6 +1976,23 @@ impl Peer { Ok(resources) } + /// Call [`Peer::list_skills`] until every page is listed. + pub async fn list_all_skills(&self) -> Result, ServiceError> { + let mut skills = Vec::new(); + let mut cursor = None; + loop { + let result = self + .list_skills(Some(PaginatedRequestParams { meta: None, cursor })) + .await?; + skills.extend(result.skills); + cursor = result.next_cursor; + if cursor.is_none() { + break; + } + } + Ok(skills) + } + /// A wrapper method for [`Peer::list_resource_templates`]. /// /// This function will call [`Peer::list_resource_templates`] multiple times until all resource templates are listed. diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 760d9e18c..5772b096c 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -773,6 +773,34 @@ "name" ] }, + "GetSkillRequestMethod": { + "type": "string", + "format": "const", + "const": "skills/get" + }, + "GetSkillRequestParams": { + "description": "Parameters for fetching one skill by the URI of its `SKILL.md`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "URI of the skill's `SKILL.md`.", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, "GetTaskMethod": { "type": "string", "format": "const", @@ -1112,6 +1140,15 @@ { "$ref": "#/definitions/Request13" }, + { + "$ref": "#/definitions/RequestOptionalParam5" + }, + { + "$ref": "#/definitions/Request14" + }, + { + "$ref": "#/definitions/Request15" + }, { "$ref": "#/definitions/CustomRequest" } @@ -1185,6 +1222,11 @@ "roots" ] }, + "ListSkillsRequestMethod": { + "type": "string", + "format": "const", + "const": "skills/list" + }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -1385,6 +1427,40 @@ "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", "type": "string" }, + "ReadResourceDirectoryRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/directory/read" + }, + "ReadResourceDirectoryRequestParams": { + "description": "Parameters for listing the direct children of a directory resource.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "cursor": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "URI of the directory resource.", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, "ReadResourceRequestMethod": { "type": "string", "format": "const", @@ -1547,6 +1623,38 @@ "params" ] }, + "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": { + "method": { + "$ref": "#/definitions/GetSkillRequestMethod" + }, + "params": { + "$ref": "#/definitions/GetSkillRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "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": { + "method": { + "$ref": "#/definitions/ReadResourceDirectoryRequestMethod" + }, + "params": { + "$ref": "#/definitions/ReadResourceDirectoryRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, "Request2": { "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", @@ -1792,6 +1900,27 @@ "method" ] }, + "RequestOptionalParam5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListSkillsRequestMethod" + }, + "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", 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..5772b096c 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 @@ -773,6 +773,34 @@ "name" ] }, + "GetSkillRequestMethod": { + "type": "string", + "format": "const", + "const": "skills/get" + }, + "GetSkillRequestParams": { + "description": "Parameters for fetching one skill by the URI of its `SKILL.md`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "URI of the skill's `SKILL.md`.", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, "GetTaskMethod": { "type": "string", "format": "const", @@ -1112,6 +1140,15 @@ { "$ref": "#/definitions/Request13" }, + { + "$ref": "#/definitions/RequestOptionalParam5" + }, + { + "$ref": "#/definitions/Request14" + }, + { + "$ref": "#/definitions/Request15" + }, { "$ref": "#/definitions/CustomRequest" } @@ -1185,6 +1222,11 @@ "roots" ] }, + "ListSkillsRequestMethod": { + "type": "string", + "format": "const", + "const": "skills/list" + }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -1385,6 +1427,40 @@ "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", "type": "string" }, + "ReadResourceDirectoryRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/directory/read" + }, + "ReadResourceDirectoryRequestParams": { + "description": "Parameters for listing the direct children of a directory resource.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "cursor": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "URI of the directory resource.", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, "ReadResourceRequestMethod": { "type": "string", "format": "const", @@ -1547,6 +1623,38 @@ "params" ] }, + "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": { + "method": { + "$ref": "#/definitions/GetSkillRequestMethod" + }, + "params": { + "$ref": "#/definitions/GetSkillRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "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": { + "method": { + "$ref": "#/definitions/ReadResourceDirectoryRequestMethod" + }, + "params": { + "$ref": "#/definitions/ReadResourceDirectoryRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, "Request2": { "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", @@ -1792,6 +1900,27 @@ "method" ] }, + "RequestOptionalParam5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListSkillsRequestMethod" + }, + "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", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index cebb3ed37..2792f4142 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -1154,6 +1154,59 @@ "messages" ] }, + "GetSkillResult": { + "description": "Result of `skills/get`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Who may cache this result (SEP-2549); required on 2026-07-28, absent before.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "resultType": { + "description": "Result type discriminator (SEP-2322); `None` on the wire means complete.", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "skill": { + "$ref": "#/definitions/Skill" + }, + "ttlMs": { + "description": "Freshness in milliseconds (SEP-2549); required on 2026-07-28, absent before.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "skill" + ] + }, "GetTaskResult": { "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", "type": "object", @@ -1935,6 +1988,67 @@ "format": "const", "const": "roots/list" }, + "ListSkillsResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/Skill" + } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "skills" + ] + }, "ListToolsResult": { "type": "object", "properties": { @@ -3260,6 +3374,12 @@ { "$ref": "#/definitions/ListToolsResult" }, + { + "$ref": "#/definitions/ListSkillsResult" + }, + { + "$ref": "#/definitions/GetSkillResult" + }, { "$ref": "#/definitions/ElicitResult" }, @@ -3297,6 +3417,93 @@ } ] }, + "Skill": { + "description": "One skill as listed by `skills/list` or returned by `skills/get`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "frontmatter": { + "$ref": "#/definitions/SkillFrontmatter" + }, + "resources": { + "$ref": "#/definitions/SkillResources" + }, + "uri": { + "description": "Resource URI of the skill's `SKILL.md`.", + "type": "string" + } + }, + "required": [ + "uri", + "frontmatter", + "resources" + ] + }, + "SkillFrontmatter": { + "description": "The `SKILL.md` frontmatter, with every field the author wrote.", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": true, + "required": [ + "name", + "description" + ] + }, + "SkillResource": { + "description": "One file of a skill, with the digest and size of its bytes.", + "type": "object", + "properties": { + "digest": { + "description": "`sha256:` followed by 64 lowercase hex characters.", + "type": "string" + }, + "size": { + "description": "Length of the file in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "uri": { + "description": "Resource URI of the file.", + "type": "string" + } + }, + "required": [ + "uri", + "digest", + "size" + ] + }, + "SkillResources": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/SkillResource" + } + }, + { + "type": "string", + "const": "dynamic" + } + ] + }, "StringFormat": { "description": "String format types allowed by the MCP specification.", "oneOf": [ 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..2792f4142 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 @@ -210,7 +210,7 @@ ] }, "structuredContent": { - "description": "An optional JSON object that represents the structured result of the tool call" + "description": "An optional JSON value that represents the structured result of the tool call.\nIt can be any JSON value, including `null`; an explicit `null` is kept\ndistinct from an absent field." } } }, @@ -963,6 +963,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": [ @@ -1150,6 +1154,59 @@ "messages" ] }, + "GetSkillResult": { + "description": "Result of `skills/get`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Who may cache this result (SEP-2549); required on 2026-07-28, absent before.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "resultType": { + "description": "Result type discriminator (SEP-2322); `None` on the wire means complete.", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "skill": { + "$ref": "#/definitions/Skill" + }, + "ttlMs": { + "description": "Freshness in milliseconds (SEP-2549); required on 2026-07-28, absent before.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "skill" + ] + }, "GetTaskResult": { "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", "type": "object", @@ -1931,6 +1988,67 @@ "format": "const", "const": "roots/list" }, + "ListSkillsResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/Skill" + } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "skills" + ] + }, "ListToolsResult": { "type": "object", "properties": { @@ -3256,6 +3374,12 @@ { "$ref": "#/definitions/ListToolsResult" }, + { + "$ref": "#/definitions/ListSkillsResult" + }, + { + "$ref": "#/definitions/GetSkillResult" + }, { "$ref": "#/definitions/ElicitResult" }, @@ -3293,6 +3417,93 @@ } ] }, + "Skill": { + "description": "One skill as listed by `skills/list` or returned by `skills/get`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "frontmatter": { + "$ref": "#/definitions/SkillFrontmatter" + }, + "resources": { + "$ref": "#/definitions/SkillResources" + }, + "uri": { + "description": "Resource URI of the skill's `SKILL.md`.", + "type": "string" + } + }, + "required": [ + "uri", + "frontmatter", + "resources" + ] + }, + "SkillFrontmatter": { + "description": "The `SKILL.md` frontmatter, with every field the author wrote.", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": true, + "required": [ + "name", + "description" + ] + }, + "SkillResource": { + "description": "One file of a skill, with the digest and size of its bytes.", + "type": "object", + "properties": { + "digest": { + "description": "`sha256:` followed by 64 lowercase hex characters.", + "type": "string" + }, + "size": { + "description": "Length of the file in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "uri": { + "description": "Resource URI of the file.", + "type": "string" + } + }, + "required": [ + "uri", + "digest", + "size" + ] + }, + "SkillResources": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/SkillResource" + } + }, + { + "type": "string", + "const": "dynamic" + } + ] + }, "StringFormat": { "description": "String format types allowed by the MCP specification.", "oneOf": [ diff --git a/crates/rmcp/tests/test_skills.rs b/crates/rmcp/tests/test_skills.rs new file mode 100644 index 000000000..40b6adcb1 --- /dev/null +++ b/crates/rmcp/tests/test_skills.rs @@ -0,0 +1,426 @@ +//! SEP-2640 skills extension, end to end through `Peer` over a duplex transport. +#![cfg(not(feature = "local"))] +#![cfg(all(feature = "client", feature = "server"))] + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use rmcp::{ + ClientHandler, ErrorData, RoleClient, RoleServer, ServerHandler, + model::{ + CacheScope, ClientConfig, DIRECTORY_MIME_TYPE, ErrorCode, GetSkillRequestParams, + GetSkillResult, InitializeResult, ListResourcesResult, ListSkillsResult, + PaginatedRequestParams, ProtocolVersion, ReadResourceDirectoryRequestParams, + ReadResourceDirectoryResult, ReadResourceRequestParams, ReadResourceResponse, + ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig, Skill, + SkillFrontmatter, SkillResource, SkillResources, + }, + service::{RequestContext, RunningService, ServiceError, serve_directly}, +}; +use tokio::task::JoinHandle; + +const SKILL_MD: &str = "---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\n---\n\n# PDF processing\n\nChoose the matching template from `templates/`.\n"; +const PDF_SKILL_URI: &str = "skill://pdf-processing/SKILL.md"; +const INVOICE_URI: &str = "skill://pdf-processing/templates/invoice.md"; +const TEMPLATES_DIR_URI: &str = "skill://pdf-processing/templates"; +const REGIONAL_DIR_URI: &str = "skill://pdf-processing/templates/regional"; +const DYNAMIC_SKILL_URI: &str = "skill://live-metrics/SKILL.md"; + +fn pdf_skill() -> Skill { + Skill::new( + PDF_SKILL_URI, + SkillFrontmatter::new( + "pdf-processing", + "Extract, fill, and assemble PDF documents", + ), + SkillResources::Files(vec![ + SkillResource::from_bytes(PDF_SKILL_URI, SKILL_MD.as_bytes()), + SkillResource::from_bytes(INVOICE_URI, b"# Invoice\n"), + ]), + ) +} + +fn dynamic_skill() -> Skill { + Skill::new( + DYNAMIC_SKILL_URI, + SkillFrontmatter::new("live-metrics", "Metrics regenerated on every read"), + SkillResources::Dynamic, + ) +} + +/// Serves two skills one per page, the `pdf-processing` files, and one +/// directory level. Flags switch on the behaviours the tests probe. +#[derive(Debug, Clone, Default)] +struct SkillServer { + declare_skills: bool, + declare_directory_read: bool, + answer_get_with_other_uri: bool, + directory_calls: Arc, +} + +impl SkillServer { + fn with_directory_read() -> Self { + Self { + declare_skills: true, + declare_directory_read: true, + ..Default::default() + } + } + + fn without_directory_read() -> Self { + Self { + declare_skills: true, + ..Default::default() + } + } +} + +impl ServerHandler for SkillServer { + fn get_info(&self) -> ServerConfig { + let builder = ServerCapabilities::builder().enable_resources(); + let capabilities = match (self.declare_skills, self.declare_directory_read) { + (true, true) => builder.enable_skills_with_directory_read().build(), + (true, false) => builder.enable_skills().build(), + (false, _) => builder.build(), + }; + ServerConfig::new(capabilities) + } + + async fn list_skills( + &self, + request: Option, + _context: RequestContext, + ) -> Result { + let cursor = request.and_then(|params| params.cursor); + let mut result = match cursor.as_deref() { + None => ListSkillsResult::with_all_items(vec![pdf_skill()]), + Some("page-2") => ListSkillsResult::with_all_items(vec![dynamic_skill()]), + Some(other) => { + return Err(ErrorData::invalid_params( + format!("unknown cursor {other}"), + None, + )); + } + }; + if cursor.is_none() { + result.next_cursor = Some("page-2".into()); + } + Ok(result) + } + + async fn get_skill( + &self, + request: GetSkillRequestParams, + _context: RequestContext, + ) -> Result { + if self.answer_get_with_other_uri { + return Ok(GetSkillResult::new(dynamic_skill())); + } + match request.uri.as_str() { + PDF_SKILL_URI => Ok(GetSkillResult::new(pdf_skill())), + DYNAMIC_SKILL_URI => Ok(GetSkillResult::new(dynamic_skill())), + uri => Err(ErrorData::invalid_params( + format!("no skill at {uri}"), + None, + )), + } + } + + async fn read_resource_directory( + &self, + request: ReadResourceDirectoryRequestParams, + _context: RequestContext, + ) -> Result { + self.directory_calls.fetch_add(1, Ordering::SeqCst); + if request.uri != TEMPLATES_DIR_URI { + return Err(ErrorData::invalid_params( + format!("{} is not a directory", request.uri), + None, + )); + } + Ok(ListResourcesResult::with_all_items(vec![ + Resource::new(INVOICE_URI, "invoice.md").with_mime_type("text/markdown"), + Resource::new(REGIONAL_DIR_URI, "regional").with_mime_type(DIRECTORY_MIME_TYPE), + ])) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let text = match request.uri.as_str() { + PDF_SKILL_URI => SKILL_MD, + INVOICE_URI => "# Invoice\n", + uri => { + return Err(ErrorData::resource_not_found( + format!("no resource at {uri}"), + None, + )); + } + }; + Ok(ReadResourceResult::new(vec![ResourceContents::text(text, request.uri)]).into()) + } +} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +type Client = RunningService; +type ServerTask = JoinHandle>; + +/// Wires the pair up with the version already agreed and the server's +/// capabilities known to the client, as the discover lifecycle leaves them. +fn connect(server: SkillServer, protocol_version: ProtocolVersion) -> (Client, ServerTask) { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let client_handler = VersionedClient { + protocol_version: protocol_version.clone(), + }; + let mut server_peer_info = InitializeResult::new(server.get_info().capabilities); + server_peer_info.protocol_version = protocol_version; + + let server = serve_directly::( + server, + server_transport, + Some(client_handler.get_info()), + ); + let server_task = tokio::spawn(async move { + server.waiting().await?; + anyhow::Ok(()) + }); + let client = serve_directly::( + client_handler, + client_transport, + Some(server_peer_info.into()), + ); + (client, server_task) +} + +async fn disconnect(client: Client, server_task: ServerTask) { + client.cancel().await.expect("client should cancel"); + server_task.await.expect("server task").expect("server"); +} + +fn error_code(error: ServiceError) -> ErrorCode { + match error { + ServiceError::McpError(error) => error.code, + other => panic!("expected an MCP error, got {other:?}"), + } +} + +#[tokio::test] +async fn list_all_skills_should_follow_the_cursor_across_pages() { + let (client, server_task) = connect( + SkillServer::with_directory_read(), + ProtocolVersion::V_2026_07_28, + ); + + let skills = client.list_all_skills().await.expect("skills/list"); + disconnect(client, server_task).await; + + let names: Vec<_> = skills.iter().map(Skill::name).collect(); + assert_eq!(names, ["pdf-processing", "live-metrics"]); +} + +#[tokio::test] +async fn list_skills_should_get_default_cache_hints_for_2026_07_28() { + let (client, server_task) = connect( + SkillServer::with_directory_read(), + ProtocolVersion::V_2026_07_28, + ); + + let page = client.list_skills(None).await.expect("skills/list"); + disconnect(client, server_task).await; + + assert_eq!( + (page.ttl_ms, page.cache_scope), + (Some(0), Some(CacheScope::Private)) + ); +} + +#[tokio::test] +async fn list_skills_should_omit_result_type_for_legacy_peers() { + let (client, server_task) = connect( + SkillServer::with_directory_read(), + ProtocolVersion::V_2025_11_25, + ); + + let page = client.list_skills(None).await.expect("skills/list"); + disconnect(client, server_task).await; + + assert_eq!( + (page.result_type, page.ttl_ms, page.cache_scope), + (None, None, None) + ); +} + +#[tokio::test] +async fn get_skill_should_return_the_entry_for_a_served_uri() { + let (client, server_task) = connect( + SkillServer::with_directory_read(), + ProtocolVersion::V_2026_07_28, + ); + + let result = client + .get_skill(GetSkillRequestParams::new(PDF_SKILL_URI)) + .await + .expect("skills/get"); + disconnect(client, server_task).await; + + assert_eq!(result.skill, pdf_skill()); +} + +#[tokio::test] +async fn get_skill_should_pass_the_server_invalid_params_error_through() { + let (client, server_task) = connect( + SkillServer::with_directory_read(), + ProtocolVersion::V_2026_07_28, + ); + + let error = client + .get_skill(GetSkillRequestParams::new("skill://nowhere/SKILL.md")) + .await + .expect_err("unknown skill"); + disconnect(client, server_task).await; + + assert_eq!(error_code(error), ErrorCode::INVALID_PARAMS); +} + +#[tokio::test] +async fn get_skill_should_reject_a_uri_that_is_not_a_skill_md_before_the_handler() { + let (client, server_task) = connect( + SkillServer::with_directory_read(), + ProtocolVersion::V_2026_07_28, + ); + + let error = client + .get_skill(GetSkillRequestParams::new(INVOICE_URI)) + .await + .expect_err("not a SKILL.md"); + disconnect(client, server_task).await; + + assert_eq!(error_code(error), ErrorCode::INVALID_PARAMS); +} + +#[tokio::test] +async fn get_skill_should_fail_with_internal_error_when_the_handler_answers_another_uri() { + let server = SkillServer { + answer_get_with_other_uri: true, + ..SkillServer::with_directory_read() + }; + let (client, server_task) = connect(server, ProtocolVersion::V_2026_07_28); + + let error = client + .get_skill(GetSkillRequestParams::new(PDF_SKILL_URI)) + .await + .expect_err("mismatched uri"); + disconnect(client, server_task).await; + + assert_eq!(error_code(error), ErrorCode::INTERNAL_ERROR); +} + +#[tokio::test] +async fn skills_methods_should_be_method_not_found_when_the_extension_is_undeclared() { + let (client, server_task) = connect(SkillServer::default(), ProtocolVersion::V_2026_07_28); + + let error = client.list_skills(None).await.expect_err("undeclared"); + disconnect(client, server_task).await; + + assert_eq!(error_code(error), ErrorCode::METHOD_NOT_FOUND); +} + +#[tokio::test] +async fn read_resource_directory_should_not_send_when_directory_read_is_undeclared() { + let server = SkillServer::without_directory_read(); + let calls = server.directory_calls.clone(); + let (client, server_task) = connect(server, ProtocolVersion::V_2026_07_28); + + let error = client + .read_resource_directory(ReadResourceDirectoryRequestParams::new(TEMPLATES_DIR_URI)) + .await + .expect_err("undeclared directoryRead"); + disconnect(client, server_task).await; + + assert_eq!( + (error_code(error), calls.load(Ordering::SeqCst)), + (ErrorCode::METHOD_NOT_FOUND, 0) + ); +} + +#[tokio::test] +async fn read_resource_directory_should_list_children_with_directories_marked() { + let (client, server_task) = connect( + SkillServer::with_directory_read(), + ProtocolVersion::V_2026_07_28, + ); + + let listing = client + .read_resource_directory(ReadResourceDirectoryRequestParams::new(TEMPLATES_DIR_URI)) + .await + .expect("resources/directory/read"); + disconnect(client, server_task).await; + + let directories: Vec<_> = listing + .resources + .iter() + .filter(|resource| resource.is_directory()) + .map(|resource| resource.uri.as_str()) + .collect(); + assert_eq!(directories, [REGIONAL_DIR_URI]); +} + +#[tokio::test] +async fn read_resource_directory_should_pass_invalid_params_through_for_a_file() { + let (client, server_task) = connect( + SkillServer::with_directory_read(), + ProtocolVersion::V_2026_07_28, + ); + + let error = client + .read_resource_directory(ReadResourceDirectoryRequestParams::new(INVOICE_URI)) + .await + .expect_err("not a directory"); + disconnect(client, server_task).await; + + assert_eq!(error_code(error), ErrorCode::INVALID_PARAMS); +} + +#[tokio::test] +async fn skill_md_read_through_resources_should_verify_against_the_manifest() { + let (client, server_task) = connect( + SkillServer::with_directory_read(), + ProtocolVersion::V_2026_07_28, + ); + + let manifest = client + .get_skill(GetSkillRequestParams::new(PDF_SKILL_URI)) + .await + .expect("skills/get"); + let contents = client + .read_resource(ReadResourceRequestParams::new(PDF_SKILL_URI)) + .await + .expect("resources/read"); + disconnect(client, server_task).await; + + let SkillResources::Files(files) = &manifest.skill.resources else { + panic!("expected a file manifest"); + }; + let entry = files.iter().find(|file| file.uri == PDF_SKILL_URI).unwrap(); + let Some(ResourceContents::TextResourceContents { text, .. }) = contents.contents.first() + else { + panic!("expected text contents, got {:?}", contents.contents); + }; + assert_eq!(entry.verify(text.as_bytes()), Ok(())); +}