From 85a50f706a6485b5a4509b782bf88f1570f8b6c5 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Tue, 15 Sep 2026 10:29:01 -0700 Subject: [PATCH 01/26] feat(rmcp): implement SEP-2640 MCP Skills Extension Add server-side dispatch for skills/list, skills/get, and resources/directory/read, plus 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 the new model/skills.rs module: 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. Skills are transported as MCP Resources (SEP-2640); this commit lands the wire types and dispatch, with the macro trio and a demo server following. --- 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 72334ff496c9d0d1492b6da2609034e8499126a9 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Fri, 18 Sep 2026 13:02:59 -0700 Subject: [PATCH 02/26] feat(rmcp): implement SEP-2640 skills macros + file-system demo server Add the #[skill] macro trio (skill, skill_router, skill_handler) so users can declare skill endpoints via proc-macro annotations, analogous to the existing #[tool] and #[prompt] trios. The dispatch key is a skill:// URI rather than a flat name, so the router resolves requests by longest-prefix URI match. SkillRouter + SkillRoute runtime types live in handler/server/router/skill.rs; the ServerHandler methods (skills_list, skills_get, resources_directory_read) are wired through handle_request in handler/server.rs. FileSystemSkillServer (examples/skill_fs_server.rs) walks **/SKILL.md from a directory tree, parses frontmatter, computes SHA-256 digests for sibling files, and serves entries over stdio. It is demo code - users should implement their own ServerHandler via the #[skill] macro. A validation client (examples/skill_fs_client.rs) spawns the server over stdio and exercises list/get/error paths end-to-end. Test suite (tests/test_skills_conformance.rs) drives the server binary as a child process over stdio JSON-RPC, 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 8c7465cc76ffa9e8e2563ef2198e0a5c2f183256 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Fri, 18 Sep 2026 17:51:36 -0700 Subject: [PATCH 03/26] chore(rmcp): fix clippy warnings and formatting Cleanup across the skills macros, handlers, and example server: resolve clippy -D warnings (unused imports, needless borrows) and apply cargo +nightly fmt. No behavior change. --- 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); From 83d10974657829cb0abcfe48485be597fdd8ae52 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Wed, 23 Sep 2026 05:38:25 -0700 Subject: [PATCH 04/26] test: update message schema snapshots for SEP-2640 skills requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema snapshot tests (test_client_json_rpc_message_schema, test_server_json_rpc_message_schema) failed on CI because SEP-2640 adds new request types (SubscribeRequest, skills requests) to Client/Server JsonRpcMessage unions, changing the generated schemas. Regenerated with UPDATE_SCHEMA=1 cargo test -p rmcp --test test_message_schema --features 'server client schemars'. Verified without the update flag: 3 passed, 0 failed — the same command CI runs. Diff is purely additive: new union members + their definitions, plus definition renumbering (Request9->RequestOptionalParam4 etc.) from schemars ordering. --- .../client_json_rpc_message_schema.json | 151 ++++++- .../server_json_rpc_message_schema.json | 379 ++++++++++++++++++ 2 files changed, 518 insertions(+), 12 deletions(-) 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..66acb94a4 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 @@ -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.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index e220078d7..184a0f045 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 @@ -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", @@ -1670,6 +1692,15 @@ }, { "$ref": "#/definitions/CustomRequest" + }, + { + "$ref": "#/definitions/Request3" + }, + { + "$ref": "#/definitions/Request4" + }, + { + "$ref": "#/definitions/RequestOptionalParam" } ], "required": [ @@ -2334,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", @@ -2646,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", @@ -2690,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", @@ -2958,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" @@ -3254,6 +3438,15 @@ { "$ref": "#/definitions/ReadResourceResult" }, + { + "$ref": "#/definitions/ResourcesDirectoryReadResult" + }, + { + "$ref": "#/definitions/SkillsGetResult" + }, + { + "$ref": "#/definitions/SkillsListResult" + }, { "$ref": "#/definitions/SubscriptionsListenResult" }, @@ -3297,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": [ From fcaef81fb4af00a97cd7c60a9a6b55d513fd6dea Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sat, 10 Oct 2026 05:12:58 -0700 Subject: [PATCH 05/26] fix(rmcp-macros): export skill macro trio + fix expansion bugs Three defects fixed: 1. Macros never exported from rmcp-macros/src/lib.rs (crate-wide allow(dead_code) hid the silence) 2. enable_skills() called non-existent builder method - added SkillsCapability struct + skills field on ServerCapabilities 3. SkillEntry is #[non_exhaustive] - changed struct literal to SkillEntry::new() + field assignment Also fixed: - SkillsListResult::new() instead of struct literal (no meta field) - cache_scope as String not CacheScope enum - DirectoryEntry::new() instead of struct literal (non_exhaustive) - Some(_) arm for non-exhaustive SkillResources match - Handler returns Result with SkillCallContext - Blanket impl of CallSkillHandler for function pointers - Clone bound on IntoSkillRoute for 'static future generation - Removed unused mut + ReturnType import (clippy -D warnings) Schema snapshots regenerated (purely additive: SkillsCapability). Gates: cargo +nightly fmt --all OK cargo clippy --all-targets --all-features -- -D warnings OK cargo test --all-features OK (9 passed, 0 failed in conformance) --- crates/rmcp-macros/src/lib.rs | 36 + crates/rmcp-macros/src/skill.rs | 89 +- crates/rmcp-macros/src/skill_handler.rs | 41 +- crates/rmcp-macros/src/skill_router.rs | 3 +- .../rmcp/src/handler/server/router/skill.rs | 2 +- crates/rmcp/src/handler/server/skill.rs | 32 + crates/rmcp/src/model/capabilities.rs | 17 + .../server_json_rpc_message_schema.json | 23 + ...erver_json_rpc_message_schema_current.json | 4496 ----------------- 9 files changed, 167 insertions(+), 4572 deletions(-) delete mode 100644 crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index 5c635ab79..7bbac8ace 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -334,3 +334,39 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> TokenStream { .unwrap_or_else(|err| err.to_compile_error()) .into() } + +/// # skill +/// +/// Marks a function as a skill handler for the SEP-2640 Skills extension. +/// Generates a companion `*_skill_attr()` function returning the skill's +/// [`SkillEntry`] metadata, and wraps the async body in `Pin>`. +#[proc_macro_attribute] +pub fn skill(attr: TokenStream, input: TokenStream) -> TokenStream { + skill::skill(attr.into(), input.into()) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// # skill_router +/// +/// Walks an impl block for `#[skill]`-annotated functions and builds a +/// [`SkillRouter`] mapping each skill's `skill://` URI to its handler. +#[proc_macro_attribute] +pub fn skill_router(attr: TokenStream, input: TokenStream) -> TokenStream { + skill_router::skill_router(attr.into(), input.into()) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// # skill_handler +/// +/// Implements the `ServerHandler` skills trait methods (`skills_list`, +/// `skills_get`, `resources_directory_read`) for a struct, wiring them +/// through a `SkillRouter`. Also generates `get_info()` with +/// `.enable_skills()` if no sibling `tool_handler` is present. +#[proc_macro_attribute] +pub fn skill_handler(attr: TokenStream, input: TokenStream) -> TokenStream { + skill_handler::skill_handler(attr.into(), input.into()) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} diff --git a/crates/rmcp-macros/src/skill.rs b/crates/rmcp-macros/src/skill.rs index c8b5d6a98..c694fb362 100644 --- a/crates/rmcp-macros/src/skill.rs +++ b/crates/rmcp-macros/src/skill.rs @@ -9,7 +9,7 @@ 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 syn::{Expr, Ident, ImplItemFn, LitStr, parse_quote}; use crate::common::extract_doc_line; @@ -60,12 +60,9 @@ impl ResolvedSkillAttribute { 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, - } + let mut entry = rmcp::model::skills::SkillEntry::new(#uri, #frontmatter); + entry.resources = #resources; + entry } }; syn::parse2::(tokens) @@ -79,7 +76,7 @@ pub fn skill(attr: TokenStream, input: TokenStream) -> syn::Result let attr_args = NestedMeta::parse_meta_list(attr)?; SkillAttribute::from_list(&attr_args)? }; - let mut fn_item = syn::parse2::(input.clone())?; + let fn_item = syn::parse2::(input.clone())?; let fn_ident = &fn_item.sig.ident; let skill_attr_fn_ident = format_ident!("{}_skill_attr", fn_ident); @@ -142,50 +139,50 @@ pub fn skill(attr: TokenStream, input: TokenStream) -> syn::Result description: description_expr, dynamic: attribute.dynamic, }; - let skill_attr_fn = resolved.into_fn(skill_attr_fn_ident)?; + let skill_attr_fn = resolved.into_fn(skill_attr_fn_ident.clone())?; - // 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() - && let syn::ReceiverKind::Reference(_, receiver_lt, _) = &receiver.kind - { - if let Some(receiver_lt) = receiver_lt { - lt = quote! { #receiver_lt }; - } else { - lt = quote! { '_ }; - } + // Generate the CallSkillHandler wrapper. + // The user's function is the "body" — it takes &self and returns Result<(), ErrorData>. + // The router needs a fn that takes &self + SkillCallContext and returns Result. + // We generate a wrapper that calls the user's fn, then returns the SkillEntry from *_skill_attr(). + // The original function is kept so the wrapper can call it. + let handler_fn_ident = format_ident!("{}_handler", fn_ident); + let skill_attr_fn_ident_clone = skill_attr_fn_ident.clone(); + + let handler_fn = if fn_item.sig.asyncness.is_some() { + quote! { + #[allow(clippy::too_many_arguments)] + fn #handler_fn_ident( + &self, + ctx: rmcp::handler::server::skill::SkillCallContext<'_, Self>, + ) -> ::std::pin::Pin> + Send + 'static>> { + let this = self.clone(); + Box::pin(async move { + let _ = ctx.service; + this.#fn_ident().await?; + Ok(Self::#skill_attr_fn_ident_clone()) + }) } - 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>> } - } - } + } + } else { + quote! { + #[allow(clippy::too_many_arguments)] + fn #handler_fn_ident( + &self, + _ctx: rmcp::handler::server::skill::SkillCallContext<'_, Self>, + ) -> ::std::pin::Pin> + Send + 'static>> { + let this = self.clone(); + Box::pin(async move { + this.#fn_ident()?; + Ok(Self::#skill_attr_fn_ident_clone()) + }) } - })?; - 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 + #handler_fn }) } diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index 9d8e5a72d..f368d8ef8 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -99,15 +99,11 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result= 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), - }) + let mut result = rmcp::model::skills::SkillsListResult::new(#router.list_all()); + result.result_type = Some(rmcp::model::ResultType::COMPLETE); + result.ttl_ms = supports_cache_hints.then_some(0); + result.cache_scope = supports_cache_hints.then_some("public".to_string()); + Ok(result) } })?; item_impl.items.push(skill_list_fn); @@ -132,12 +128,7 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result { 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, - } + rmcp::model::skills::DirectoryEntry::file(file.uri.clone(), name) }).collect() } Some(rmcp::model::skills::SkillResources::Dynamic) => vec![], + Some(_) => 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), - }) + let mut result = rmcp::model::skills::ResourcesDirectoryReadResult::new(children); + result.result_type = Some(rmcp::model::ResultType::COMPLETE); + result.ttl_ms = supports_cache_hints.then_some(0); + result.cache_scope = supports_cache_hints.then_some("public".to_string()); + Ok(result) } })?; item_impl.items.push(directory_read_fn); diff --git a/crates/rmcp-macros/src/skill_router.rs b/crates/rmcp-macros/src/skill_router.rs index cd5ae1bca..f335ca1a7 100644 --- a/crates/rmcp-macros/src/skill_router.rs +++ b/crates/rmcp-macros/src/skill_router.rs @@ -76,8 +76,9 @@ pub fn skill_router(attr: TokenStream, input: TokenStream) -> syn::Result { impl IntoSkillRoute for (T, C) where - S: MaybeSend + 'static, + S: MaybeSend + Clone + 'static, C: CallSkillHandler + MaybeSend + Clone + 'static, T: Into, { diff --git a/crates/rmcp/src/handler/server/skill.rs b/crates/rmcp/src/handler/server/skill.rs index 5ecf93998..57fd8bf44 100644 --- a/crates/rmcp/src/handler/server/skill.rs +++ b/crates/rmcp/src/handler/server/skill.rs @@ -49,6 +49,38 @@ pub trait CallSkillHandler: Send + Sync + Clone + 'static { >; } +impl CallSkillHandler for F +where + F: for<'a> Fn( + &'a S, + SkillCallContext<'a, S>, + ) -> ::std::pin::Pin< + Box< + dyn ::std::future::Future> + + Send + + 'static, + >, + > + Send + + Sync + + Clone + + 'static, + S: crate::service::MaybeSend + 'static, +{ + fn call( + &self, + service: &S, + context: SkillCallContext, + ) -> ::std::pin::Pin< + Box< + dyn ::std::future::Future> + + Send + + 'static, + >, + > { + self(service, context) + } +} + /// Type-erased skill call handler for storage in SkillRouter. pub type DynCallSkillHandler = dyn for<'a> Fn(SkillCallContext<'a, S>) -> MaybeBoxFuture<'a, Result> + Send diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index f014f569f..7d475635f 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -60,6 +60,19 @@ pub struct ToolsCapability { pub list_changed: Option, } +/// Capability for the SEP-2640 Skills extension. +/// +/// Advertises support for `skills/list`, `skills/get`, and +/// `resources/directory/read` RPC methods. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SkillsCapability { + #[serde(skip_serializing_if = "Option::is_none")] + pub list_changed: Option, +} + /// Roots capability. Deprecated by SEP-2577; remains functional and will be /// removed in a future release. /// See . @@ -240,6 +253,9 @@ pub struct ServerCapabilities { pub resources: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option, + /// Capability for the SEP-2640 Skills extension. + #[serde(skip_serializing_if = "Option::is_none")] + pub skills: Option, } impl ServerCapabilities { @@ -376,6 +392,7 @@ builder! { prompts: PromptsCapability, resources: ResourcesCapability, tools: ToolsCapability, + skills: SkillsCapability, } } 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 184a0f045..3e26291f7 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 @@ -3400,6 +3400,17 @@ } ] }, + "skills": { + "description": "Capability for the SEP-2640 Skills extension.", + "anyOf": [ + { + "$ref": "#/definitions/SkillsCapability" + }, + { + "type": "null" + } + ] + }, "tools": { "anyOf": [ { @@ -3577,6 +3588,18 @@ } ] }, + "SkillsCapability": { + "description": "Capability for the SEP-2640 Skills extension.\n\nAdvertises support for `skills/list`, `skills/get`, and\n`resources/directory/read` RPC methods.", + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, "SkillsGetRequestMethod": { "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 deleted file mode 100644 index 184a0f045..000000000 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ /dev/null @@ -1,4496 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "JsonRpcMessage", - "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", - "anyOf": [ - { - "description": "A single request expecting a response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcRequest" - } - ] - }, - { - "description": "A response to a previous request", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcResponse" - } - ] - }, - { - "description": "A one-way notification (no response expected)", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcNotification" - } - ] - }, - { - "description": "An error response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcError" - } - ] - } - ], - "definitions": { - "Annotations": { - "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", - "type": "object", - "properties": { - "audience": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Role" - } - }, - "lastModified": { - "type": [ - "string", - "null" - ] - }, - "priority": { - "type": [ - "number", - "null" - ], - "format": "float" - } - } - }, - "ArrayTypeConst": { - "type": "string", - "format": "const", - "const": "array" - }, - "AudioContent": { - "description": "Audio content with base64-encoded data (spec `AudioContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded audio data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the audio (e.g. `audio/wav`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "BooleanSchema": { - "description": "Schema definition for boolean properties.", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "boolean", - "null" - ] - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/BooleanTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "BooleanTypeConst": { - "type": "string", - "format": "const", - "const": "boolean" - }, - "CacheScope": { - "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", - "oneOf": [ - { - "description": "Any client or intermediary may cache and serve the response to any user.", - "type": "string", - "const": "public" - }, - { - "description": "Only the requesting user's client may cache the response.", - "type": "string", - "const": "private" - } - ] - }, - "CallToolResult": { - "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "description": "The content returned by the tool (text, images, etc.)", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/ContentBlock" - } - }, - "isError": { - "description": "Whether this result represents an error condition", - "type": [ - "boolean", - "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" - } - ] - }, - "structuredContent": { - "description": "An optional JSON object that represents the structured result of the tool call" - } - } - }, - "CancelledNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/cancelled" - }, - "CancelledNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "reason": { - "type": [ - "string", - "null" - ] - }, - "requestId": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - } - } - }, - "ClientCapabilities": { - "title": "Builder", - "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", - "type": "object", - "properties": { - "elicitation": { - "description": "Capability to handle elicitation requests from servers for interactive user input", - "anyOf": [ - { - "$ref": "#/definitions/ElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "experimental": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "extensions": { - "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "roots": { - "description": "Capability for filesystem roots (deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/RootsCapabilities" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "CompleteResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "completion": { - "$ref": "#/definitions/CompletionInfo" - }, - "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" - } - ] - } - }, - "required": [ - "completion" - ] - }, - "CompletionInfo": { - "type": "object", - "properties": { - "hasMore": { - "type": [ - "boolean", - "null" - ] - }, - "total": { - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "values": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "values" - ] - }, - "ConstTitle": { - "description": "Schema definition for enum properties.\n\nRepresent single entry for titled item", - "type": "object", - "properties": { - "const": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "required": [ - "const", - "title" - ] - }, - "ContentBlock": { - "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/EmbeddedResource" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource_link" - } - }, - "allOf": [ - { - "$ref": "#/definitions/Resource" - } - ], - "required": [ - "type" - ] - } - ] - }, - "ContextInclusion": { - "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", - "oneOf": [ - { - "description": "Include context from all connected MCP servers", - "type": "string", - "const": "allServers" - }, - { - "description": "Include no additional context", - "type": "string", - "const": "none" - }, - { - "description": "Include context only from the requesting server", - "type": "string", - "const": "thisServer" - } - ] - }, - "CreateMessageRequestMethod": { - "type": "string", - "format": "const", - "const": "sampling/createMessage" - }, - "CreateMessageRequestParams": { - "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.\n\nThis implements `TaskAugmentedRequestParamsMeta` as sampling requests can be\nlong-running and may benefit from task-based execution.", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "includeContext": { - "description": "How much context to include from MCP servers", - "anyOf": [ - { - "$ref": "#/definitions/ContextInclusion" - }, - { - "type": "null" - } - ] - }, - "maxTokens": { - "description": "Maximum number of tokens to generate", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "messages": { - "description": "The conversation history and current messages", - "type": "array", - "items": { - "$ref": "#/definitions/SamplingMessage" - } - }, - "metadata": { - "description": "Additional metadata for the request" - }, - "modelPreferences": { - "description": "Preferences for model selection and behavior", - "anyOf": [ - { - "$ref": "#/definitions/ModelPreferences" - }, - { - "type": "null" - } - ] - }, - "stopSequences": { - "description": "Sequences that should stop generation", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "systemPrompt": { - "description": "System prompt to guide the model's behavior", - "type": [ - "string", - "null" - ] - }, - "temperature": { - "description": "Temperature for controlling randomness (0.0 to 1.0)", - "type": [ - "number", - "null" - ], - "format": "float" - }, - "toolChoice": { - "description": "Tool selection behavior (SEP-1577)", - "anyOf": [ - { - "$ref": "#/definitions/ToolChoice" - }, - { - "type": "null" - } - ] - }, - "tools": { - "description": "Tools available for the model to call (SEP-1577)", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Tool" - } - } - }, - "deprecated": true, - "required": [ - "messages", - "maxTokens" - ] - }, - "CreateTaskResult": { - "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO 8601 timestamp when the task was created.", - "type": "string" - }, - "lastUpdatedAt": { - "description": "ISO 8601 timestamp when the task was last updated.", - "type": "string" - }, - "pollIntervalMs": { - "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "resultType": { - "description": "Always `\"task\"`.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - }, - "status": { - "description": "Current task status.", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Stable identifier for this task, generated by the server.", - "type": "string" - }, - "ttlMs": { - "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resultType", - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "CustomNotification": { - "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "CustomRequest": { - "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "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", - "properties": { - "_meta": { - "description": "Protocol-level response metadata.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Whether the cached result may be shared across authorization contexts.", - "allOf": [ - { - "$ref": "#/definitions/CacheScope" - } - ] - }, - "capabilities": { - "description": "Capabilities provided by this server.", - "allOf": [ - { - "$ref": "#/definitions/ServerCapabilities" - } - ] - }, - "instructions": { - "description": "Optional guidance for using the server.", - "type": [ - "string", - "null" - ] - }, - "resultType": { - "description": "Identifies how the result should be parsed.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - }, - "supportedVersions": { - "description": "Protocol versions implemented by this server.", - "type": "array", - "items": { - "$ref": "#/definitions/ProtocolVersion" - } - }, - "ttlMs": { - "description": "How long clients may consider this response fresh, in milliseconds.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resultType", - "supportedVersions", - "capabilities", - "ttlMs", - "cacheScope" - ] - }, - "ElicitRequestParams": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", - "anyOf": [ - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "type": "string" - }, - "mode": { - "type": "string", - "const": "form" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" - } - }, - "required": [ - "mode", - "message", - "requestedSchema" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "elicitationId": { - "type": "string" - }, - "message": { - "type": "string" - }, - "mode": { - "type": "string", - "const": "url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "mode", - "message", - "url", - "elicitationId" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "type": "string" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" - } - }, - "required": [ - "message", - "requestedSchema" - ] - } - ] - }, - "ElicitResult": { - "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "action": { - "description": "The user's decision on how to handle the elicitation request", - "allOf": [ - { - "$ref": "#/definitions/ElicitationAction" - } - ] - }, - "content": { - "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." - } - }, - "required": [ - "action" - ] - }, - "ElicitationAction": { - "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", - "oneOf": [ - { - "description": "User accepts the request and provides the requested information", - "type": "string", - "const": "accept" - }, - { - "description": "User declines to provide the information but allows the operation to continue", - "type": "string", - "const": "decline" - }, - { - "description": "User cancels the entire operation", - "type": "string", - "const": "cancel" - } - ] - }, - "ElicitationCapability": { - "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", - "type": "object", - "properties": { - "form": { - "description": "Whether client supports form-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/FormElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "url": { - "description": "Whether client supports URL-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/UrlElicitationCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "ElicitationCreateRequestMethod": { - "type": "string", - "format": "const", - "const": "elicitation/create" - }, - "ElicitationSchema": { - "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": [ - "string", - "null" - ] - }, - "properties": { - "description": "Property definitions (must be primitive types)", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/PrimitiveSchemaDefinition" - } - }, - "required": { - "description": "List of required property names", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Always \"object\" for elicitation schemas", - "allOf": [ - { - "$ref": "#/definitions/ObjectTypeConst" - } - ] - } - }, - "required": [ - "type", - "properties" - ] - }, - "EmbeddedResource": { - "description": "Embedded resource content (spec `EmbeddedResource`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "description": "The embedded resource contents (text or blob).", - "allOf": [ - { - "$ref": "#/definitions/ResourceContents" - } - ] - } - }, - "required": [ - "resource" - ] - }, - "EmptyObject": { - "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", - "type": "object", - "additionalProperties": false - }, - "EnumSchema": { - "description": "Compliant with MCP 2025-06-18 specification for elicitation schemas.\nEnums must have string type for values and can optionally include human-readable names.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet enum_schema = EnumSchema::builder(vec![\"US\".to_string(), \"UK\".to_string()])\n .multiselect()\n .min_items(1u64).expect(\"Min items should be correct value\")\n .max_items(4u64).expect(\"Max items should be correct value\")\n .description(\"Country code\")\n .build();\n```", - "anyOf": [ - { - "$ref": "#/definitions/SingleSelectEnumSchema" - }, - { - "$ref": "#/definitions/MultiSelectEnumSchema" - }, - { - "$ref": "#/definitions/LegacyEnumSchema" - } - ] - }, - "ErrorCode": { - "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", - "type": "integer", - "format": "int32" - }, - "ErrorData": { - "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", - "type": "object", - "properties": { - "code": { - "description": "The error type that occurred (using standard JSON-RPC error codes)", - "allOf": [ - { - "$ref": "#/definitions/ErrorCode" - } - ] - }, - "data": { - "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." - }, - "message": { - "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", - "type": "string" - } - }, - "required": [ - "code", - "message" - ] - }, - "FormElicitationCapability": { - "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", - "type": "object", - "properties": { - "schemaValidation": { - "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", - "type": [ - "boolean", - "null" - ] - } - } - }, - "GetPromptResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/definitions/PromptMessage" - } - }, - "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" - } - ] - } - }, - "required": [ - "messages" - ] - }, - "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", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO 8601 timestamp when the task was created.", - "type": "string" - }, - "error": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputRequests": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/InputRequest" - } - }, - "lastUpdatedAt": { - "description": "ISO 8601 timestamp when the task was last updated.", - "type": "string" - }, - "pollIntervalMs": { - "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "result": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "resultType": { - "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ], - "default": "complete" - }, - "status": { - "description": "Current task status.", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Stable identifier for this task, generated by the server.", - "type": "string" - }, - "ttlMs": { - "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "Icon": { - "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", - "type": "object", - "properties": { - "mimeType": { - "description": "Optional override if the server's MIME type is missing or generic", - "type": [ - "string", - "null" - ] - }, - "sizes": { - "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "src": { - "description": "A standard URI pointing to an icon resource", - "type": "string" - }, - "theme": { - "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", - "anyOf": [ - { - "$ref": "#/definitions/IconTheme" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "src" - ] - }, - "IconTheme": { - "description": "Icon themes supported by the MCP specification", - "oneOf": [ - { - "description": "Indicates the icon is designed to be used with a light background", - "type": "string", - "const": "light" - }, - { - "description": "Indicates the icon is designed to be used with a dark background", - "type": "string", - "const": "dark" - } - ] - }, - "ImageContent": { - "description": "Image content with base64-encoded data (spec `ImageContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded image data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the image (e.g. `image/png`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "Implementation": { - "type": "object", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "version": { - "type": "string" - }, - "websiteUrl": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name", - "version" - ] - }, - "InitializeResult": { - "description": "The server's response to an initialization request.\n\nContains the server's protocol version, capabilities, and implementation\ninformation, along with optional instructions for the client.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "capabilities": { - "description": "The capabilities this server provides (tools, resources, prompts, etc.)", - "allOf": [ - { - "$ref": "#/definitions/ServerCapabilities" - } - ] - }, - "instructions": { - "description": "Optional human-readable instructions about using this server", - "type": [ - "string", - "null" - ] - }, - "protocolVersion": { - "description": "The MCP protocol version this server supports", - "allOf": [ - { - "$ref": "#/definitions/ProtocolVersion" - } - ] - }, - "serverInfo": { - "description": "Information about the server implementation", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - } - }, - "required": [ - "protocolVersion", - "capabilities", - "serverInfo" - ] - }, - "InputRequest": { - "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", - "anyOf": [ - { - "description": "A `sampling/createMessage` request.", - "allOf": [ - { - "$ref": "#/definitions/Request" - } - ] - }, - { - "description": "An `elicitation/create` request.", - "allOf": [ - { - "$ref": "#/definitions/Request2" - } - ] - }, - { - "description": "A `roots/list` request.", - "allOf": [ - { - "$ref": "#/definitions/RequestNoParam2" - } - ] - } - ] - }, - "InputRequiredResult": { - "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "inputRequests": { - "description": "Server-initiated requests that the client must fulfill before retrying.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/InputRequest" - } - }, - "requestState": { - "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", - "type": [ - "string", - "null" - ] - }, - "resultType": { - "description": "Always `\"input_required\"` for this result type.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - } - }, - "required": [ - "resultType" - ] - }, - "IntegerSchema": { - "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "integer", - "null" - ], - "format": "int64" - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "maximum": { - "description": "Maximum value (inclusive)", - "type": [ - "integer", - "null" - ], - "format": "int64" - }, - "minimum": { - "description": "Minimum value (inclusive)", - "type": [ - "integer", - "null" - ], - "format": "int64" - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/IntegerTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "IntegerTypeConst": { - "type": "string", - "format": "const", - "const": "integer" - }, - "JsonRpcError": { - "type": "object", - "properties": { - "error": { - "$ref": "#/definitions/ErrorData" - }, - "id": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "required": [ - "jsonrpc", - "error" - ] - }, - "JsonRpcNotification": { - "type": "object", - "properties": { - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/Notification" - }, - { - "$ref": "#/definitions/Notification2" - }, - { - "$ref": "#/definitions/Notification3" - }, - { - "$ref": "#/definitions/Notification4" - }, - { - "$ref": "#/definitions/NotificationNoParam" - }, - { - "$ref": "#/definitions/NotificationNoParam2" - }, - { - "$ref": "#/definitions/NotificationNoParam3" - }, - { - "$ref": "#/definitions/Notification5" - }, - { - "$ref": "#/definitions/Notification6" - }, - { - "$ref": "#/definitions/CustomNotification" - } - ], - "required": [ - "jsonrpc" - ] - }, - "JsonRpcRequest": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/RequestNoParam" - }, - { - "$ref": "#/definitions/Request" - }, - { - "$ref": "#/definitions/RequestNoParam2" - }, - { - "$ref": "#/definitions/Request2" - }, - { - "$ref": "#/definitions/CustomRequest" - }, - { - "$ref": "#/definitions/Request3" - }, - { - "$ref": "#/definitions/Request4" - }, - { - "$ref": "#/definitions/RequestOptionalParam" - } - ], - "required": [ - "jsonrpc", - "id" - ] - }, - "JsonRpcResponse": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - }, - "result": { - "$ref": "#/definitions/ServerResult" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ] - }, - "JsonRpcVersion2_0": { - "type": "string", - "format": "const", - "const": "2.0" - }, - "LegacyEnumSchema": { - "description": "Legacy enum schema, keep for backward compatibility", - "type": "object", - "properties": { - "default": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "enumNames": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "required": [ - "type", - "enum" - ] - }, - "ListPromptsResult": { - "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" - ] - }, - "prompts": { - "type": "array", - "items": { - "$ref": "#/definitions/Prompt" - } - }, - "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" - } - ] - }, - "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": [ - "prompts" - ] - }, - "ListResourceTemplatesResult": { - "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" - ] - }, - "resourceTemplates": { - "type": "array", - "items": { - "$ref": "#/definitions/ResourceTemplate" - } - }, - "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" - } - ] - }, - "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": [ - "resourceTemplates" - ] - }, - "ListResourcesResult": { - "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" - ] - }, - "resources": { - "type": "array", - "items": { - "$ref": "#/definitions/Resource" - } - }, - "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" - } - ] - }, - "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": [ - "resources" - ] - }, - "ListRootsRequestMethod": { - "type": "string", - "format": "const", - "const": "roots/list" - }, - "ListToolsResult": { - "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" - } - ] - }, - "tools": { - "type": "array", - "items": { - "$ref": "#/definitions/Tool" - } - }, - "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": [ - "tools" - ] - }, - "LoggingLevel": { - "description": "Logging levels supported by the MCP protocol", - "type": "string", - "deprecated": true, - "enum": [ - "debug", - "info", - "notice", - "warning", - "error", - "critical", - "alert", - "emergency" - ] - }, - "LoggingMessageNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/message" - }, - "LoggingMessageNotificationParam": { - "description": "Parameters for a logging message notification", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The actual log data" - }, - "level": { - "description": "The severity level of this log message", - "allOf": [ - { - "$ref": "#/definitions/LoggingLevel" - } - ] - }, - "logger": { - "description": "Optional logger name that generated this message", - "type": [ - "string", - "null" - ] - } - }, - "deprecated": true, - "required": [ - "level", - "data" - ] - }, - "MetaObject": { - "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", - "type": "object", - "additionalProperties": true - }, - "ModelHint": { - "description": "A hint suggesting a preferred model name or family.\n\nModel hints are advisory suggestions that help clients choose appropriate\nmodels. They can be specific model names or general families like \"claude\" or \"gpt\".", - "type": "object", - "properties": { - "name": { - "description": "The suggested model name or family identifier", - "type": [ - "string", - "null" - ] - } - }, - "deprecated": true - }, - "ModelPreferences": { - "description": "Preferences for model selection and behavior in sampling requests.\n\nThis allows servers to express their preferences for which model to use\nand how to balance different priorities when the client has multiple\nmodel options available.", - "type": "object", - "properties": { - "costPriority": { - "description": "Priority for cost optimization (0.0 to 1.0, higher = prefer cheaper models)", - "type": [ - "number", - "null" - ], - "format": "float" - }, - "hints": { - "description": "Specific model names or families to prefer (e.g., \"claude\", \"gpt\")", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/ModelHint" - } - }, - "intelligencePriority": { - "description": "Priority for intelligence/capability (0.0 to 1.0, higher = prefer more capable models)", - "type": [ - "number", - "null" - ], - "format": "float" - }, - "speedPriority": { - "description": "Priority for speed/latency (0.0 to 1.0, higher = prefer faster models)", - "type": [ - "number", - "null" - ], - "format": "float" - } - }, - "deprecated": true - }, - "MultiSelectEnumSchema": { - "description": "Multi-select enum options", - "anyOf": [ - { - "$ref": "#/definitions/UntitledMultiSelectEnumSchema" - }, - { - "$ref": "#/definitions/TitledMultiSelectEnumSchema" - } - ] - }, - "Notification": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CancelledNotificationMethod" - }, - "params": { - "$ref": "#/definitions/CancelledNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ProgressNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ProgressNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/LoggingMessageNotificationMethod" - }, - "params": { - "$ref": "#/definitions/LoggingMessageNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification4": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ResourceUpdatedNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ResourceUpdatedNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod" - }, - "params": { - "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification6": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/TaskStatusNotificationMethod" - }, - "params": { - "$ref": "#/definitions/TaskStatusNotificationParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "NotificationMetaObject": { - "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/subscriptionId": { - "$ref": "#/definitions/NumberOrString" - } - }, - "additionalProperties": true - }, - "NotificationNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ResourceListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NotificationNoParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ToolListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NotificationNoParam3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/PromptListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NumberOrString": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string" - } - ] - }, - "NumberSchema": { - "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "number", - "null" - ], - "format": "double" - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "maximum": { - "description": "Maximum value (inclusive)", - "type": [ - "number", - "null" - ], - "format": "double" - }, - "minimum": { - "description": "Minimum value (inclusive)", - "type": [ - "number", - "null" - ], - "format": "double" - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/NumberTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "NumberTypeConst": { - "type": "string", - "format": "const", - "const": "number" - }, - "ObjectTypeConst": { - "type": "string", - "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", - "const": "ping" - }, - "PrimitiveSchemaDefinition": { - "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", - "anyOf": [ - { - "description": "Enum property (explicit enum schema)", - "allOf": [ - { - "$ref": "#/definitions/EnumSchema" - } - ] - }, - { - "description": "String property (with optional enum constraint)", - "allOf": [ - { - "$ref": "#/definitions/StringSchema" - } - ] - }, - { - "description": "Number property (with optional enum constraint)", - "allOf": [ - { - "$ref": "#/definitions/NumberSchema" - } - ] - }, - { - "description": "Integer property (with optional enum constraint)", - "allOf": [ - { - "$ref": "#/definitions/IntegerSchema" - } - ] - }, - { - "description": "Boolean property", - "allOf": [ - { - "$ref": "#/definitions/BooleanSchema" - } - ] - } - ] - }, - "ProgressNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/progress" - }, - "ProgressNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "description": "An optional message describing the current progress.", - "type": [ - "string", - "null" - ] - }, - "progress": { - "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", - "type": "number", - "format": "double" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - }, - "total": { - "description": "Total number of items to process (or total progress required), if known", - "type": [ - "number", - "null" - ], - "format": "double" - } - }, - "required": [ - "progressToken", - "progress" - ] - }, - "ProgressToken": { - "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", - "allOf": [ - { - "$ref": "#/definitions/NumberOrString" - } - ] - }, - "Prompt": { - "description": "A prompt or prompt template that the server offers (spec `Prompt`).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "arguments": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/PromptArgument" - } - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "PromptArgument": { - "description": "Describes an argument that a prompt can accept (spec `PromptArgument`).", - "type": "object", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "required": { - "type": [ - "boolean", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "PromptListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/prompts/list_changed" - }, - "PromptMessage": { - "description": "A message returned as part of a prompt (spec `PromptMessage`).\n\nUses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).", - "type": "object", - "properties": { - "content": { - "$ref": "#/definitions/ContentBlock" - }, - "role": { - "$ref": "#/definitions/Role" - } - }, - "required": [ - "role", - "content" - ] - }, - "PromptsCapability": { - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "ProtocolVersion": { - "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" - }, - "ReadResourceResult": { - "description": "Result containing the contents of a read resource", - "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" - } - ] - }, - "contents": { - "description": "The actual content of the resource", - "type": "array", - "items": { - "$ref": "#/definitions/ResourceContents" - } - }, - "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" - } - ] - }, - "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": [ - "contents" - ] - }, - "Request": { - "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/CreateMessageRequestMethod" - }, - "params": { - "$ref": "#/definitions/CreateMessageRequestParams" - } - }, - "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", - "properties": { - "method": { - "$ref": "#/definitions/ElicitationCreateRequestMethod" - }, - "params": { - "$ref": "#/definitions/ElicitRequestParams" - } - }, - "required": [ - "method", - "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", - "properties": { - "io.modelcontextprotocol/clientCapabilities": { - "$ref": "#/definitions/ClientCapabilities" - }, - "io.modelcontextprotocol/clientInfo": { - "$ref": "#/definitions/Implementation" - }, - "io.modelcontextprotocol/logLevel": { - "$ref": "#/definitions/LoggingLevel" - }, - "io.modelcontextprotocol/protocolVersion": { - "type": "string" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - } - }, - "additionalProperties": true - }, - "RequestNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/PingRequestMethod" - } - }, - "required": [ - "method" - ] - }, - "RequestNoParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListRootsRequestMethod" - } - }, - "required": [ - "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", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this resource.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this resource.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of what this resource represents.", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional set of icons the client may display for this resource.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "The MIME type of this resource, if known.", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "The programmatic name of the resource.", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "description": "Optional human-readable display title.", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "The URI of this resource (e.g. `file:///path/to/file`).", - "type": "string" - } - }, - "required": [ - "uri", - "name" - ] - }, - "ResourceContents": { - "description": "The contents of a specific resource or sub-resource.", - "anyOf": [ - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "text": { - "type": "string" - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "text" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "blob": { - "type": "string" - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "blob" - ] - } - ] - }, - "ResourceListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/resources/list_changed" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server (spec `ResourceTemplate`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this resource template.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this template.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of what this template is for.", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional set of icons the client may display for this template.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "The MIME type for resources matching this template, if uniform.", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "The programmatic name of the resource template.", - "type": "string" - }, - "title": { - "description": "Optional human-readable display title.", - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "description": "An RFC 6570 URI template for constructing resource URIs.", - "type": "string" - } - }, - "required": [ - "uriTemplate", - "name" - ] - }, - "ResourceUpdatedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/resources/updated" - }, - "ResourceUpdatedNotificationParam": { - "description": "Parameters for a resource update notification", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "uri": { - "description": "The URI of the resource that was updated", - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "ResourcesCapability": { - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - }, - "subscribe": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "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" - }, - "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": [ - { - "description": "A human user or client making a request", - "type": "string", - "const": "user" - }, - { - "description": "An AI assistant or server providing a response", - "type": "string", - "const": "assistant" - } - ] - }, - "RootsCapabilities": { - "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "SamplingCapability": { - "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", - "type": "object", - "properties": { - "context": { - "description": "Support for `includeContext` (soft-deprecated)", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "tools": { - "description": "Support for `tools` and `toolChoice` parameters", - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, - "SamplingContent": { - "description": "Single or array content wrapper (SEP-1577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingMessageContentBlock" - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/SamplingMessageContentBlock" - } - } - ] - }, - "SamplingMessage": { - "description": "A message in a sampling conversation, containing a role and content.\n\nThis represents a single message in a conversation flow, used primarily\nin LLM sampling requests where the conversation history is important\nfor generating appropriate responses.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "description": "The actual content of the message (text, image, audio, tool use, or tool result)", - "allOf": [ - { - "$ref": "#/definitions/SamplingContent" - } - ] - }, - "role": { - "description": "The role of the message sender (User or Assistant)", - "allOf": [ - { - "$ref": "#/definitions/Role" - } - ] - } - }, - "deprecated": true, - "required": [ - "role", - "content" - ] - }, - "SamplingMessageContentBlock": { - "description": "Content types for sampling messages (SEP-1577).", - "deprecated": true, - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "Assistant only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_use" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolUseContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "User only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_result" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolResultContent" - } - ], - "required": [ - "type" - ] - } - ] - }, - "ServerCapabilities": { - "title": "Builder", - "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", - "type": "object", - "properties": { - "completions": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "experimental": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "extensions": { - "description": "Optional MCP extensions that the server supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/apps\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "logging": { - "description": "Capability for server log message notifications (deprecated by SEP-2577).", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "prompts": { - "anyOf": [ - { - "$ref": "#/definitions/PromptsCapability" - }, - { - "type": "null" - } - ] - }, - "resources": { - "anyOf": [ - { - "$ref": "#/definitions/ResourcesCapability" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "ServerResult": { - "anyOf": [ - { - "$ref": "#/definitions/DiscoverResult" - }, - { - "$ref": "#/definitions/InitializeResult" - }, - { - "$ref": "#/definitions/CompleteResult" - }, - { - "$ref": "#/definitions/GetPromptResult" - }, - { - "$ref": "#/definitions/ListPromptsResult" - }, - { - "$ref": "#/definitions/ListResourcesResult" - }, - { - "$ref": "#/definitions/ListResourceTemplatesResult" - }, - { - "$ref": "#/definitions/ReadResourceResult" - }, - { - "$ref": "#/definitions/ResourcesDirectoryReadResult" - }, - { - "$ref": "#/definitions/SkillsGetResult" - }, - { - "$ref": "#/definitions/SkillsListResult" - }, - { - "$ref": "#/definitions/SubscriptionsListenResult" - }, - { - "$ref": "#/definitions/ListToolsResult" - }, - { - "$ref": "#/definitions/ElicitResult" - }, - { - "$ref": "#/definitions/CreateTaskResult" - }, - { - "$ref": "#/definitions/GetTaskResult" - }, - { - "$ref": "#/definitions/CallToolResult" - }, - { - "$ref": "#/definitions/InputRequiredResult" - }, - { - "$ref": "#/definitions/TaskAckResult" - }, - { - "$ref": "#/definitions/EmptyObject" - }, - { - "$ref": "#/definitions/CustomResult" - } - ] - }, - "SingleSelectEnumSchema": { - "description": "Combined single-select", - "anyOf": [ - { - "$ref": "#/definitions/UntitledSingleSelectEnumSchema" - }, - { - "$ref": "#/definitions/TitledSingleSelectEnumSchema" - } - ] - }, - "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": [ - { - "description": "Email address format", - "type": "string", - "const": "email" - }, - { - "description": "URI format", - "type": "string", - "const": "uri" - }, - { - "description": "Date format (YYYY-MM-DD)", - "type": "string", - "const": "date" - }, - { - "description": "Date-time format (ISO 8601)", - "type": "string", - "const": "date-time" - } - ] - }, - "StringSchema": { - "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "string", - "null" - ] - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "format": { - "description": "String format - limited to: \"email\", \"uri\", \"date\", \"date-time\"", - "anyOf": [ - { - "$ref": "#/definitions/StringFormat" - }, - { - "type": "null" - } - ] - }, - "maxLength": { - "description": "Maximum string length", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "minLength": { - "description": "Minimum string length", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/StringTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "StringTypeConst": { - "type": "string", - "format": "const", - "const": "string" - }, - "SubscriptionFilter": { - "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", - "type": "object", - "properties": { - "promptsListChanged": { - "type": "boolean" - }, - "resourceSubscriptions": { - "type": "array", - "items": { - "type": "string" - } - }, - "resourcesListChanged": { - "type": "boolean" - }, - "toolsListChanged": { - "type": "boolean" - } - } - }, - "SubscriptionsAcknowledgedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/subscriptions/acknowledged" - }, - "SubscriptionsAcknowledgedNotificationParams": { - "description": "Parameters reporting the accepted subset of a subscription filter.", - "type": "object", - "properties": { - "_meta": { - "$ref": "#/definitions/NotificationMetaObject" - }, - "notifications": { - "$ref": "#/definitions/SubscriptionFilter" - } - }, - "required": [ - "notifications" - ] - }, - "SubscriptionsListenResult": { - "description": "Final response indicating that a subscription ended gracefully.", - "type": "object", - "properties": { - "_meta": { - "$ref": "#/definitions/SubscriptionsListenResultMeta" - }, - "resultType": { - "$ref": "#/definitions/ResultType" - } - }, - "required": [ - "resultType", - "_meta" - ] - }, - "SubscriptionsListenResultMeta": { - "type": "object", - "properties": { - "io.modelcontextprotocol/serverInfo": { - "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - }, - "io.modelcontextprotocol/subscriptionId": { - "$ref": "#/definitions/NumberOrString" - } - }, - "additionalProperties": true, - "required": [ - "io.modelcontextprotocol/subscriptionId" - ] - }, - "TaskAckResult": { - "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "resultType": { - "description": "Always `\"complete\"`.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - } - }, - "required": [ - "resultType" - ] - }, - "TaskStatus": { - "description": "Canonical task lifecycle status (SEP-2663).", - "oneOf": [ - { - "description": "The request is currently being processed.", - "type": "string", - "const": "working" - }, - { - "description": "The server needs input from the client before the task can proceed.", - "type": "string", - "const": "input_required" - }, - { - "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", - "type": "string", - "const": "completed" - }, - { - "description": "The request failed due to a JSON-RPC error during execution.", - "type": "string", - "const": "failed" - }, - { - "description": "The request was cancelled before completion.", - "type": "string", - "const": "cancelled" - } - ] - }, - "TaskStatusNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tasks" - }, - "TaskStatusNotificationParams": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO 8601 timestamp when the task was created.", - "type": "string" - }, - "error": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputRequests": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/InputRequest" - } - }, - "lastUpdatedAt": { - "description": "ISO 8601 timestamp when the task was last updated.", - "type": "string" - }, - "pollIntervalMs": { - "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "result": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "status": { - "description": "Current task status.", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Stable identifier for this task, generated by the server.", - "type": "string" - }, - "ttlMs": { - "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "TextContent": { - "description": "Text content block (spec `TextContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "text": { - "description": "The text content of the message.", - "type": "string" - } - }, - "required": [ - "text" - ] - }, - "TitledItems": { - "description": "Items for titled multi-select options", - "type": "object", - "properties": { - "anyOf": { - "type": "array", - "items": { - "$ref": "#/definitions/ConstTitle" - } - } - }, - "required": [ - "anyOf" - ] - }, - "TitledMultiSelectEnumSchema": { - "description": "Multi-select titled options", - "type": "object", - "properties": { - "default": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "items": { - "$ref": "#/definitions/TitledItems" - }, - "maxItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "minItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/ArrayTypeConst" - } - }, - "required": [ - "type", - "items" - ] - }, - "TitledSingleSelectEnumSchema": { - "description": "Titled single-select", - "type": "object", - "properties": { - "default": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "oneOf": { - "type": "array", - "items": { - "$ref": "#/definitions/ConstTitle" - } - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "required": [ - "type", - "oneOf" - ] - }, - "Tool": { - "description": "A tool that can be used by a model.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional additional metadata for this tool", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional additional tool information.", - "anyOf": [ - { - "$ref": "#/definitions/ToolAnnotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "A description of what the tool does", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the tool", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "inputSchema": { - "description": "A JSON Schema object defining the expected parameters for the tool", - "type": "object", - "additionalProperties": true - }, - "name": { - "description": "The name of the tool", - "type": "string" - }, - "outputSchema": { - "description": "An optional JSON Schema object defining the structure of the tool's output", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "title": { - "description": "A human-readable title for the tool", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name", - "inputSchema" - ] - }, - "ToolAnnotations": { - "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", - "type": "object", - "properties": { - "destructiveHint": { - "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true\nA human-readable description of the tool's purpose.", - "type": [ - "boolean", - "null" - ] - }, - "idempotentHint": { - "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false.", - "type": [ - "boolean", - "null" - ] - }, - "openWorldHint": { - "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", - "type": [ - "boolean", - "null" - ] - }, - "readOnlyHint": { - "description": "If true, the tool does not modify its environment.\n\nDefault: false", - "type": [ - "boolean", - "null" - ] - }, - "title": { - "description": "A human-readable title for the tool.", - "type": [ - "string", - "null" - ] - } - } - }, - "ToolChoice": { - "description": "Tool choice configuration (SEP-1577).", - "type": "object", - "properties": { - "mode": { - "anyOf": [ - { - "$ref": "#/definitions/ToolChoiceMode" - }, - { - "type": "null" - } - ] - } - }, - "deprecated": true - }, - "ToolChoiceMode": { - "description": "Tool selection mode (SEP-1577).", - "oneOf": [ - { - "description": "Model decides whether to use tools", - "type": "string", - "const": "auto" - }, - { - "description": "Model must use at least one tool", - "type": "string", - "const": "required" - }, - { - "description": "Model must not use tools", - "type": "string", - "const": "none" - } - ] - }, - "ToolListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tools/list_changed" - }, - "ToolResultContent": { - "description": "Tool execution result in user message (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "type": "array", - "items": { - "$ref": "#/definitions/ContentBlock" - } - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true, - "toolUseId": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "toolUseId", - "content" - ] - }, - "ToolUseContent": { - "description": "Tool call request from assistant (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "input": { - "type": "object", - "additionalProperties": true - }, - "name": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "id", - "name", - "input" - ] - }, - "ToolsCapability": { - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "UntitledItems": { - "description": "Items for untitled multi-select options", - "type": "object", - "properties": { - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "required": [ - "type", - "enum" - ] - }, - "UntitledMultiSelectEnumSchema": { - "description": "Multi-select untitled options", - "type": "object", - "properties": { - "default": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "items": { - "$ref": "#/definitions/UntitledItems" - }, - "maxItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "minItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/ArrayTypeConst" - } - }, - "required": [ - "type", - "items" - ] - }, - "UntitledSingleSelectEnumSchema": { - "description": "Untitled single-select", - "type": "object", - "properties": { - "default": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "additionalProperties": false, - "required": [ - "type", - "enum" - ] - }, - "UrlElicitationCapability": { - "description": "Capability for URL mode elicitation.", - "type": "object" - } - } -} \ No newline at end of file From 752882d935e725ed34d34bb1edb49d293d624ae2 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sat, 10 Oct 2026 05:17:46 -0700 Subject: [PATCH 06/26] fix(model): remove skill requests from ServerRequest union SkillsGetRequest, SkillsListRequest, and ResourcesDirectoryReadRequest are client-to-server requests but were also added to ServerRequest (server-to-client). Removed from: - meta.rs ServerRequest enum variant list - model.rs ServerRequest::method() match arms - model.rs ts_union! export - handler/client.rs match arms Schema snapshots regenerated (subtractive: -3 union members). Gates: cargo +nightly fmt --all OK cargo clippy --all-targets --all-features -- -D warnings OK cargo test -p rmcp --test test_skills_conformance OK (10/10) cargo test -p rmcp --test test_message_schema OK (3/3) --- crates/rmcp/src/handler/client.rs | 33 ---- crates/rmcp/src/model.rs | 8 +- crates/rmcp/src/model/meta.rs | 3 - .../server_json_rpc_message_schema.json | 149 ------------------ 4 files changed, 1 insertion(+), 192 deletions(-) diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index d0f81ec42..4af1a470b 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -36,39 +36,6 @@ 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/model.rs b/crates/rmcp/src/model.rs index ce4ed3023..28bc7a541 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -4558,9 +4558,6 @@ impl ServerRequest { 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(), } } } @@ -4597,10 +4594,7 @@ ts_union!( | CreateMessageRequest | ListRootsRequest | ElicitRequest - | CustomRequest - | ResourcesDirectoryReadRequest - | SkillsGetRequest - | SkillsListRequest; + | CustomRequest; ); ts_union!( diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 385d42ff7..2c4938b15 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -204,9 +204,6 @@ variant_extension! { ListRootsRequest ElicitRequest CustomRequest - ResourcesDirectoryReadRequest - SkillsGetRequest - SkillsListRequest } } 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 3e26291f7..0d6202588 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 @@ -1692,15 +1692,6 @@ }, { "$ref": "#/definitions/CustomRequest" - }, - { - "$ref": "#/definitions/Request3" - }, - { - "$ref": "#/definitions/Request4" - }, - { - "$ref": "#/definitions/RequestOptionalParam" } ], "required": [ @@ -2365,28 +2356,6 @@ "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", @@ -2699,38 +2668,6 @@ "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", @@ -2775,27 +2712,6 @@ "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", @@ -3064,39 +2980,6 @@ } } }, - "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", @@ -3600,33 +3483,6 @@ } } }, - "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", @@ -3649,11 +3505,6 @@ "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", From 45f82bff53f2050202064736db2f4265fe88e0e0 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sat, 10 Oct 2026 06:09:49 -0700 Subject: [PATCH 07/26] fix(rmcp): declare SEP-2640 skills capability + correct skills/get error code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defect 8: The skill_handler macro generated get_info() with .enable_skills(), but ServerCapabilitiesBuilder had no such method, and no code path advertised the io.modelcontextprotocol/skills extension in the extensions map. A compliant client would never call skills/* methods. - Add SKILLS_EXTENSION_ID constant ("io.modelcontextprotocol/skills") - Replace non-spec listChanged field on SkillsCapability with directory_read - Add SkillsCapability::new() constructor - Add enable_skills_extension() to ServerCapabilitiesBuilder - Fix skill_handler macro to call enable_skills_with(SkillsCapability::new(true)), enable_skills_extension(), and enable_resources() - Fix default skills_get error: was method_not_found (-32601), spec requires invalid_params (-32602) for unknown skill URIs Gates: cargo check -p rmcp --all-features ✓, 10/10 conformance tests ✓ --- crates/rmcp-macros/src/skill_handler.rs | 5 +++- crates/rmcp/src/handler/server.rs | 5 +++- crates/rmcp/src/model/capabilities.rs | 31 ++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index f368d8ef8..eced72d2c 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -48,7 +48,10 @@ pub(crate) fn build_get_info( let mut capability_calls = Vec::new(); if has_skills { - capability_calls.push(quote! { .enable_skills() }); + capability_calls + .push(quote! { .enable_skills_with(rmcp::model::SkillsCapability::new(true)) }); + capability_calls.push(quote! { .enable_skills_extension() }); + capability_calls.push(quote! { .enable_resources() }); } let server_info_expr = match (name, version) { (Some(n), Some(v)) => quote! { rmcp::model::Implementation::new(#n, #v) }, diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 6510f35a3..b1465f826 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -477,7 +477,10 @@ macro_rules! server_handler_methods { request: SkillsGetRequestParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) + std::future::ready(Err(McpError::invalid_params( + format!("skill not found: {}", request.uri), + None, + ))) } fn resources_directory_read( &self, diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 7d475635f..41014bf23 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -60,17 +60,32 @@ pub struct ToolsCapability { pub list_changed: Option, } +/// Extension identifier for the SEP-2640 Skills extension. +pub const SKILLS_EXTENSION_ID: &str = "io.modelcontextprotocol/skills"; + /// Capability for the SEP-2640 Skills extension. /// /// Advertises support for `skills/list`, `skills/get`, and /// `resources/directory/read` RPC methods. +/// +/// The only spec-defined setting is `directoryRead`. A server that supports +/// `resources/directory/read` MUST set it to `true`. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct SkillsCapability { #[serde(skip_serializing_if = "Option::is_none")] - pub list_changed: Option, + pub directory_read: Option, +} + +impl SkillsCapability { + /// Create a new `SkillsCapability` with `directoryRead` set. + pub fn new(directory_read: bool) -> Self { + Self { + directory_read: Some(directory_read), + } + } } /// Roots capability. Deprecated by SEP-2577; remains functional and will be @@ -451,6 +466,20 @@ impl ServerCapabilitiesBuilder { } } +#[cfg(any(feature = "server", feature = "macros"))] +impl ServerCapabilitiesBuilder { + /// Advertise the `io.modelcontextprotocol/skills` extension (SEP-2640) in the + /// `extensions` capability map with `directoryRead: true`. + pub fn enable_skills_extension(mut self) -> Self { + let mut dir_read = JsonObject::new(); + dir_read.insert("directoryRead".to_string(), serde_json::json!(true)); + self.extensions + .get_or_insert_with(ExtensionCapabilities::new) + .insert(super::SKILLS_EXTENSION_ID.to_string(), dir_read); + self + } +} + #[cfg(any(feature = "server", feature = "macros"))] builder! { ClientCapabilities{ From d2257611c1f3e28c5ceb98ebc2df8217173a4ece Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sat, 10 Oct 2026 06:13:06 -0700 Subject: [PATCH 08/26] fix(rmcp-macros): skills/get returns handler's SkillEntry with frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defect 3: The skill_handler macro's default skills_get implementation called the route handler (which returns the full SkillEntry with frontmatter and resources from #[skill]), then discarded the result and returned SkillEntry::new(uri, json!({})) — an empty frontmatter. The spec requires skills/get to return the skill's frontmatter verbatim. Now the default impl returns the handler's SkillEntry directly. Gates: cargo check -p rmcp --all-features pass (verified before commit) --- crates/rmcp-macros/src/skill_handler.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index eced72d2c..4e65ca7f8 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -129,10 +129,8 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result Date: Sat, 10 Oct 2026 06:52:55 -0700 Subject: [PATCH 09/26] fix(rmcp): wire format, verbatim YAML, resources completeness, directory errors Defects 4-7 + read_resource override: Defect 6 (wire format): Changed ResourcesDirectoryReadResult from children: Vec to resources: Vec per spec. Subdirectories now carry mimeType: "inode/directory". Removed DirectoryEntry. Defect 4 (verbatim YAML): Replaced parse_simple_yaml (flat-only, rejects nested fields) with serde_yaml::from_str for full YAML support. Defect 5 (resources completeness): Added SKILL.md itself to the resources list. Previously skipped it. Defect 7 (directory read): Changed unwrap_or_default() to ok_or_else with invalid_params (-32602) for missing directories. Demo fix: Added read_resource override so resources/read returns content instead of method_not_found. Gates: cargo check -p rmcp --all-features --examples pass --- crates/rmcp-macros/src/skill_handler.rs | 6 +- crates/rmcp/Cargo.toml | 1 + crates/rmcp/examples/skill_fs_server.rs | 106 ++++++++++++++--------- crates/rmcp/src/handler/server/router.rs | 2 +- crates/rmcp/src/model/skills.rs | 46 ++-------- 5 files changed, 79 insertions(+), 82 deletions(-) diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index 4e65ca7f8..faf6a1a72 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -147,12 +147,12 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result= 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) { + let resources: 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::file(file.uri.clone(), name) + rmcp::model::Resource::new(file.uri.clone(), name) }).collect() } Some(rmcp::model::skills::SkillResources::Dynamic) => vec![], @@ -162,7 +162,7 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result 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)?; + let frontmatter: serde_json::Value = Self::parse_yaml_frontmatter(&frontmatter)?; // Canonicalize and verify the path stays within root let canonical_root = root.canonicalize().ok()?; @@ -78,6 +81,13 @@ impl FileSystemSkillServer { let parent_dir = canonical_path.parent()?; let mut resources = Vec::new(); + + // Include SKILL.md itself in the resources list (spec: resources MUST be complete) + let skill_size = fs::metadata(&canonical_path).map(|m| m.len()).unwrap_or(0); + let skill_digest = Self::compute_sha256(&canonical_path); + resources.push(SkillResource::new(uri.clone(), skill_digest, skill_size)); + + // Include sibling files (not subdirs — those are separate skills) if let Ok(entries) = fs::read_dir(parent_dir) { for entry in entries.flatten() { let sibling = entry.path(); @@ -88,7 +98,6 @@ impl FileSystemSkillServer { 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; @@ -161,36 +170,12 @@ impl FileSystemSkillServer { Some((fm.join("\n"), body.join("\n"))) } - /// Parse simple flat YAML frontmatter into a JSON object. + /// Parse YAML frontmatter into a JSON object using serde_yaml. /// - /// **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)) + /// Supports nested fields, arrays, numbers, booleans, and all YAML types. + fn parse_yaml_frontmatter(yaml: &str) -> Option { + let value: serde_yaml::Value = serde_yaml::from_str(yaml).ok()?; + serde_json::to_value(value).ok() } /// List all discovered skills, sorted by URI. @@ -206,7 +191,7 @@ impl FileSystemSkillServer { } /// List the sibling files of a skill file URI. - pub fn list_skill_directory(&self, uri: &str) -> Option> { + 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()?; @@ -233,9 +218,9 @@ impl FileSystemSkillServer { 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()) + Resource::new(entry_uri, name.to_string()).with_mime_type("inode/directory") } else { - DirectoryEntry::file(entry_uri, name.to_string()) + Resource::new(entry_uri, name.to_string()) }); } entries.sort_by(|a, b| a.name.cmp(&b.name)); @@ -273,18 +258,61 @@ impl ServerHandler for FileSystemSkillServer { request: skills::ResourcesDirectoryReadRequestParams, context: RequestContext, ) -> Result { - let children = self.list_skill_directory(&request.uri).unwrap_or_default(); + let resources = self.list_skill_directory(&request.uri).ok_or_else(|| { + rmcp::ErrorData::invalid_params(format!("directory not found: {}", request.uri), None) + })?; 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); + let mut result = skills::ResourcesDirectoryReadResult::new(resources); if supports_cache_hints { result.ttl_ms = Some(DEFAULT_CACHE_TTL_MS); result.cache_scope = Some("public".to_string()); } Ok(result) } + + async fn read_resource( + &self, + request: rmcp::model::ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let path = request.uri.strip_prefix("skill://").ok_or_else(|| { + rmcp::ErrorData::invalid_params(format!("not a skill URI: {}", request.uri), None) + })?; + let file_path = self.root.join(path); + let canonical_root = self.root.canonicalize().map_err(|e| { + rmcp::ErrorData::internal_error(format!("failed to canonicalize root: {}", e), None) + })?; + let canonical_path = file_path.canonicalize().map_err(|_| { + rmcp::ErrorData::invalid_params(format!("resource not found: {}", request.uri), None) + })?; + if !canonical_path.starts_with(&canonical_root) { + return Err(rmcp::ErrorData::invalid_params( + format!("resource outside root: {}", request.uri), + None, + )); + } + let content = fs::read_to_string(&canonical_path).map_err(|_| { + rmcp::ErrorData::invalid_params(format!("resource not found: {}", request.uri), None) + })?; + let mime_type = if canonical_path.is_dir() { + "inode/directory" + } else { + "text/markdown" + }; + Ok(rmcp::model::ReadResourceResponse::Complete( + rmcp::model::ReadResourceResult::new(vec![ + rmcp::model::ResourceContents::TextResourceContents { + uri: request.uri, + mime_type: Some(mime_type.to_string()), + text: content, + meta: None, + }, + ]), + )) + } } #[tokio::main] diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index ebf769243..1fc9f686a 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -190,7 +190,7 @@ where ClientRequest::ResourcesDirectoryReadRequest(_request) => { let result = crate::model::skills::ResourcesDirectoryReadResult { result_type: Some(crate::model::ResultType::COMPLETE), - children: vec![], + resources: vec![], next_cursor: None, ttl_ms: None, cache_scope: None, diff --git a/crates/rmcp/src/model/skills.rs b/crates/rmcp/src/model/skills.rs index 66d446936..6aea5b5aa 100644 --- a/crates/rmcp/src/model/skills.rs +++ b/crates/rmcp/src/model/skills.rs @@ -14,7 +14,7 @@ use super::{ ConstString, MetaObject, PaginatedRequestParams, Request, RequestMetaObject, RequestOptionalParam, ResultType, }; -use crate::const_string; +use crate::{const_string, model::Resource}; // ============================================================================= // Skill entry @@ -314,6 +314,9 @@ pub type ResourcesDirectoryReadRequest = /// /// Carries the `resultType` discriminator (SEP-2322) so it can be stripped for /// legacy peers the same way as other resource list results. +/// +/// Per the SEP-2640 spec, the result is `resources: []` where +/// subdirectories carry `mimeType: "inode/directory"`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] @@ -321,7 +324,7 @@ pub type ResourcesDirectoryReadRequest = pub struct ResourcesDirectoryReadResult { #[serde(default, skip_serializing_if = "Option::is_none")] pub result_type: Option, - pub children: Vec, + pub resources: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub next_cursor: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -331,10 +334,10 @@ pub struct ResourcesDirectoryReadResult { } impl ResourcesDirectoryReadResult { - pub fn new(children: Vec) -> Self { + pub fn new(resources: Vec) -> Self { Self { result_type: Some(ResultType::COMPLETE), - children, + resources, next_cursor: None, ttl_ms: None, cache_scope: None, @@ -342,41 +345,6 @@ impl ResourcesDirectoryReadResult { } } -/// 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 // ============================================================================= From f51601ea6160913174bad155c672ff210e14a6f4 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sun, 11 Oct 2026 00:24:00 -0700 Subject: [PATCH 10/26] chore(rmcp): remove allow(dead_code), regenerate schema, add macro tests Three follow-ups to the skill macro work: - Drop the crate-wide `allow(dead_code)` from rmcp-macros/src/lib.rs; the skill macros are exported now, so the blanket allow only hid real dead code. - Regenerate the server JSON-RPC message schema after the skills request additions (additive union members; definitions renumbered by schemars ordering). - Add tests/test_skill_macros.rs covering the generated dispatch, and extend the conformance suite. --- crates/rmcp-macros/src/lib.rs | 1 - crates/rmcp-macros/src/skill_handler.rs | 6 +- crates/rmcp/Cargo.toml | 5 + crates/rmcp/src/model/skills.rs | 4 +- .../server_json_rpc_message_schema.json | 42 +- ...erver_json_rpc_message_schema_current.json | 4348 +++++++++++++++++ crates/rmcp/tests/test_skill_macros.rs | 86 + crates/rmcp/tests/test_skills_conformance.rs | 29 +- 8 files changed, 4479 insertions(+), 42 deletions(-) create mode 100644 crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json create mode 100644 crates/rmcp/tests/test_skill_macros.rs diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index 7bbac8ace..9f9f6a7d7 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -1,5 +1,4 @@ #![doc = include_str!("../README.md")] -#![allow(dead_code)] #[allow(unused_imports)] use proc_macro::TokenStream; diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index faf6a1a72..810d40174 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -119,7 +119,8 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { - let route = #router.get_by_uri(&request.uri) + let router = #router; + let route = router.get_by_uri(&request.uri) .ok_or_else(|| rmcp::ErrorData::invalid_params( format!("skill not found: {}", request.uri), None, @@ -147,7 +148,8 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result= rmcp::model::ProtocolVersion::V_2026_07_28 }); // Enumerate files for multi-file skills - let resources: Vec = if let Some(route) = #router.get_by_uri(&request.uri) { + let router = #router; + let resources: 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| { diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 21181b0b4..d6d2b7f7f 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -222,6 +222,11 @@ name = "test_tool_macros" required-features = ["server", "client"] path = "tests/test_tool_macros.rs" +[[test]] +name = "test_skill_macros" +required-features = ["server", "macros"] +path = "tests/test_skill_macros.rs" + [[test]] name = "test_with_python" required-features = [ diff --git a/crates/rmcp/src/model/skills.rs b/crates/rmcp/src/model/skills.rs index 6aea5b5aa..905595547 100644 --- a/crates/rmcp/src/model/skills.rs +++ b/crates/rmcp/src/model/skills.rs @@ -527,8 +527,8 @@ mod tests { #[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"), + Resource::new("skill://a/SKILL.md", "SKILL.md"), + Resource::new("skill://b/", "b").with_mime_type("inode/directory"), ]); let json = serde_json::to_string_pretty(&resp).unwrap(); let decoded: ResourcesDirectoryReadResult = serde_json::from_str(&json).unwrap(); 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 0d6202588..106850d97 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 @@ -713,28 +713,6 @@ "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", @@ -2981,7 +2959,7 @@ } }, "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.", + "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.\n\nPer the SEP-2640 spec, the result is `resources: []` where\nsubdirectories carry `mimeType: \"inode/directory\"`.", "type": "object", "properties": { "cacheScope": { @@ -2990,18 +2968,18 @@ "null" ] }, - "children": { - "type": "array", - "items": { - "$ref": "#/definitions/DirectoryEntry" - } - }, "nextCursor": { "type": [ "string", "null" ] }, + "resources": { + "type": "array", + "items": { + "$ref": "#/definitions/Resource" + } + }, "resultType": { "anyOf": [ { @@ -3022,7 +3000,7 @@ } }, "required": [ - "children" + "resources" ] }, "ResultType": { @@ -3472,10 +3450,10 @@ ] }, "SkillsCapability": { - "description": "Capability for the SEP-2640 Skills extension.\n\nAdvertises support for `skills/list`, `skills/get`, and\n`resources/directory/read` RPC methods.", + "description": "Capability for the SEP-2640 Skills extension.\n\nAdvertises support for `skills/list`, `skills/get`, and\n`resources/directory/read` RPC methods.\n\nThe only spec-defined setting is `directoryRead`. A server that supports\n`resources/directory/read` MUST set it to `true`.", "type": "object", "properties": { - "listChanged": { + "directoryRead": { "type": [ "boolean", "null" 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 new file mode 100644 index 000000000..106850d97 --- /dev/null +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -0,0 +1,4348 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "JsonRpcMessage", + "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", + "anyOf": [ + { + "description": "A single request expecting a response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcRequest" + } + ] + }, + { + "description": "A response to a previous request", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcResponse" + } + ] + }, + { + "description": "A one-way notification (no response expected)", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcNotification" + } + ] + }, + { + "description": "An error response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcError" + } + ] + } + ], + "definitions": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", + "type": "object", + "properties": { + "audience": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Role" + } + }, + "lastModified": { + "type": [ + "string", + "null" + ] + }, + "priority": { + "type": [ + "number", + "null" + ], + "format": "float" + } + } + }, + "ArrayTypeConst": { + "type": "string", + "format": "const", + "const": "array" + }, + "AudioContent": { + "description": "Audio content with base64-encoded data (spec `AudioContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded audio data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio (e.g. `audio/wav`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "BooleanSchema": { + "description": "Schema definition for boolean properties.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "boolean", + "null" + ] + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/BooleanTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "BooleanTypeConst": { + "type": "string", + "format": "const", + "const": "boolean" + }, + "CacheScope": { + "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", + "oneOf": [ + { + "description": "Any client or intermediary may cache and serve the response to any user.", + "type": "string", + "const": "public" + }, + { + "description": "Only the requesting user's client may cache the response.", + "type": "string", + "const": "private" + } + ] + }, + "CallToolResult": { + "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "The content returned by the tool (text, images, etc.)", + "type": "array", + "default": [], + "items": { + "$ref": "#/definitions/ContentBlock" + } + }, + "isError": { + "description": "Whether this result represents an error condition", + "type": [ + "boolean", + "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" + } + ] + }, + "structuredContent": { + "description": "An optional JSON object that represents the structured result of the tool call" + } + } + }, + "CancelledNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/cancelled" + }, + "CancelledNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "requestId": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + } + } + }, + "ClientCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", + "type": "object", + "properties": { + "elicitation": { + "description": "Capability to handle elicitation requests from servers for interactive user input", + "anyOf": [ + { + "$ref": "#/definitions/ElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "experimental": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "roots": { + "description": "Capability for filesystem roots (deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/RootsCapabilities" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "CompleteResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "completion": { + "$ref": "#/definitions/CompletionInfo" + }, + "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" + } + ] + } + }, + "required": [ + "completion" + ] + }, + "CompletionInfo": { + "type": "object", + "properties": { + "hasMore": { + "type": [ + "boolean", + "null" + ] + }, + "total": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "values" + ] + }, + "ConstTitle": { + "description": "Schema definition for enum properties.\n\nRepresent single entry for titled item", + "type": "object", + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ] + }, + "ContentBlock": { + "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmbeddedResource" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "allOf": [ + { + "$ref": "#/definitions/Resource" + } + ], + "required": [ + "type" + ] + } + ] + }, + "ContextInclusion": { + "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", + "oneOf": [ + { + "description": "Include context from all connected MCP servers", + "type": "string", + "const": "allServers" + }, + { + "description": "Include no additional context", + "type": "string", + "const": "none" + }, + { + "description": "Include context only from the requesting server", + "type": "string", + "const": "thisServer" + } + ] + }, + "CreateMessageRequestMethod": { + "type": "string", + "format": "const", + "const": "sampling/createMessage" + }, + "CreateMessageRequestParams": { + "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.\n\nThis implements `TaskAugmentedRequestParamsMeta` as sampling requests can be\nlong-running and may benefit from task-based execution.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "includeContext": { + "description": "How much context to include from MCP servers", + "anyOf": [ + { + "$ref": "#/definitions/ContextInclusion" + }, + { + "type": "null" + } + ] + }, + "maxTokens": { + "description": "Maximum number of tokens to generate", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "messages": { + "description": "The conversation history and current messages", + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessage" + } + }, + "metadata": { + "description": "Additional metadata for the request" + }, + "modelPreferences": { + "description": "Preferences for model selection and behavior", + "anyOf": [ + { + "$ref": "#/definitions/ModelPreferences" + }, + { + "type": "null" + } + ] + }, + "stopSequences": { + "description": "Sequences that should stop generation", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "systemPrompt": { + "description": "System prompt to guide the model's behavior", + "type": [ + "string", + "null" + ] + }, + "temperature": { + "description": "Temperature for controlling randomness (0.0 to 1.0)", + "type": [ + "number", + "null" + ], + "format": "float" + }, + "toolChoice": { + "description": "Tool selection behavior (SEP-1577)", + "anyOf": [ + { + "$ref": "#/definitions/ToolChoice" + }, + { + "type": "null" + } + ] + }, + "tools": { + "description": "Tools available for the model to call (SEP-1577)", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Tool" + } + } + }, + "deprecated": true, + "required": [ + "messages", + "maxTokens" + ] + }, + "CreateTaskResult": { + "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "resultType": { + "description": "Always `\"task\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resultType", + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, + "CustomNotification": { + "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomRequest": { + "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomResult": { + "description": "A catch-all response either side can use for custom requests." + }, + "DiscoverResult": { + "description": "The server's response to a [`DiscoverRequest`].", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level response metadata.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Whether the cached result may be shared across authorization contexts.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ] + }, + "capabilities": { + "description": "Capabilities provided by this server.", + "allOf": [ + { + "$ref": "#/definitions/ServerCapabilities" + } + ] + }, + "instructions": { + "description": "Optional guidance for using the server.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Identifies how the result should be parsed.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "supportedVersions": { + "description": "Protocol versions implemented by this server.", + "type": "array", + "items": { + "$ref": "#/definitions/ProtocolVersion" + } + }, + "ttlMs": { + "description": "How long clients may consider this response fresh, in milliseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resultType", + "supportedVersions", + "capabilities", + "ttlMs", + "cacheScope" + ] + }, + "ElicitRequestParams": { + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "form" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "mode", + "message", + "requestedSchema" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "mode", + "message", + "url", + "elicitationId" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "message", + "requestedSchema" + ] + } + ] + }, + "ElicitResult": { + "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "action": { + "description": "The user's decision on how to handle the elicitation request", + "allOf": [ + { + "$ref": "#/definitions/ElicitationAction" + } + ] + }, + "content": { + "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + } + }, + "required": [ + "action" + ] + }, + "ElicitationAction": { + "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", + "oneOf": [ + { + "description": "User accepts the request and provides the requested information", + "type": "string", + "const": "accept" + }, + { + "description": "User declines to provide the information but allows the operation to continue", + "type": "string", + "const": "decline" + }, + { + "description": "User cancels the entire operation", + "type": "string", + "const": "cancel" + } + ] + }, + "ElicitationCapability": { + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "type": "object", + "properties": { + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ElicitationCreateRequestMethod": { + "type": "string", + "format": "const", + "const": "elicitation/create" + }, + "ElicitationSchema": { + "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": [ + "string", + "null" + ] + }, + "properties": { + "description": "Property definitions (must be primitive types)", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PrimitiveSchemaDefinition" + } + }, + "required": { + "description": "List of required property names", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Always \"object\" for elicitation schemas", + "allOf": [ + { + "$ref": "#/definitions/ObjectTypeConst" + } + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "EmbeddedResource": { + "description": "Embedded resource content (spec `EmbeddedResource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "description": "The embedded resource contents (text or blob).", + "allOf": [ + { + "$ref": "#/definitions/ResourceContents" + } + ] + } + }, + "required": [ + "resource" + ] + }, + "EmptyObject": { + "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", + "type": "object", + "additionalProperties": false + }, + "EnumSchema": { + "description": "Compliant with MCP 2025-06-18 specification for elicitation schemas.\nEnums must have string type for values and can optionally include human-readable names.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet enum_schema = EnumSchema::builder(vec![\"US\".to_string(), \"UK\".to_string()])\n .multiselect()\n .min_items(1u64).expect(\"Min items should be correct value\")\n .max_items(4u64).expect(\"Max items should be correct value\")\n .description(\"Country code\")\n .build();\n```", + "anyOf": [ + { + "$ref": "#/definitions/SingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/MultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/LegacyEnumSchema" + } + ] + }, + "ErrorCode": { + "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", + "type": "integer", + "format": "int32" + }, + "ErrorData": { + "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", + "type": "object", + "properties": { + "code": { + "description": "The error type that occurred (using standard JSON-RPC error codes)", + "allOf": [ + { + "$ref": "#/definitions/ErrorCode" + } + ] + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + }, + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", + "type": "object", + "properties": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "GetPromptResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/PromptMessage" + } + }, + "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" + } + ] + } + }, + "required": [ + "messages" + ] + }, + "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", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "resultType": { + "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, + "Icon": { + "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", + "type": "object", + "properties": { + "mimeType": { + "description": "Optional override if the server's MIME type is missing or generic", + "type": [ + "string", + "null" + ] + }, + "sizes": { + "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "src": { + "description": "A standard URI pointing to an icon resource", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", + "anyOf": [ + { + "$ref": "#/definitions/IconTheme" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "src" + ] + }, + "IconTheme": { + "description": "Icon themes supported by the MCP specification", + "oneOf": [ + { + "description": "Indicates the icon is designed to be used with a light background", + "type": "string", + "const": "light" + }, + { + "description": "Indicates the icon is designed to be used with a dark background", + "type": "string", + "const": "dark" + } + ] + }, + "ImageContent": { + "description": "Image content with base64-encoded data (spec `ImageContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded image data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image (e.g. `image/png`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "Implementation": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ] + }, + "InitializeResult": { + "description": "The server's response to an initialization request.\n\nContains the server's protocol version, capabilities, and implementation\ninformation, along with optional instructions for the client.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "capabilities": { + "description": "The capabilities this server provides (tools, resources, prompts, etc.)", + "allOf": [ + { + "$ref": "#/definitions/ServerCapabilities" + } + ] + }, + "instructions": { + "description": "Optional human-readable instructions about using this server", + "type": [ + "string", + "null" + ] + }, + "protocolVersion": { + "description": "The MCP protocol version this server supports", + "allOf": [ + { + "$ref": "#/definitions/ProtocolVersion" + } + ] + }, + "serverInfo": { + "description": "Information about the server implementation", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + } + }, + "required": [ + "protocolVersion", + "capabilities", + "serverInfo" + ] + }, + "InputRequest": { + "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", + "anyOf": [ + { + "description": "A `sampling/createMessage` request.", + "allOf": [ + { + "$ref": "#/definitions/Request" + } + ] + }, + { + "description": "An `elicitation/create` request.", + "allOf": [ + { + "$ref": "#/definitions/Request2" + } + ] + }, + { + "description": "A `roots/list` request.", + "allOf": [ + { + "$ref": "#/definitions/RequestNoParam2" + } + ] + } + ] + }, + "InputRequiredResult": { + "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "inputRequests": { + "description": "Server-initiated requests that the client must fulfill before retrying.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "requestState": { + "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Always `\"input_required\"` for this result type.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, + "IntegerSchema": { + "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/IntegerTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "IntegerTypeConst": { + "type": "string", + "format": "const", + "const": "integer" + }, + "JsonRpcError": { + "type": "object", + "properties": { + "error": { + "$ref": "#/definitions/ErrorData" + }, + "id": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "required": [ + "jsonrpc", + "error" + ] + }, + "JsonRpcNotification": { + "type": "object", + "properties": { + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/Notification" + }, + { + "$ref": "#/definitions/Notification2" + }, + { + "$ref": "#/definitions/Notification3" + }, + { + "$ref": "#/definitions/Notification4" + }, + { + "$ref": "#/definitions/NotificationNoParam" + }, + { + "$ref": "#/definitions/NotificationNoParam2" + }, + { + "$ref": "#/definitions/NotificationNoParam3" + }, + { + "$ref": "#/definitions/Notification5" + }, + { + "$ref": "#/definitions/Notification6" + }, + { + "$ref": "#/definitions/CustomNotification" + } + ], + "required": [ + "jsonrpc" + ] + }, + "JsonRpcRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/RequestNoParam" + }, + { + "$ref": "#/definitions/Request" + }, + { + "$ref": "#/definitions/RequestNoParam2" + }, + { + "$ref": "#/definitions/Request2" + }, + { + "$ref": "#/definitions/CustomRequest" + } + ], + "required": [ + "jsonrpc", + "id" + ] + }, + "JsonRpcResponse": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + }, + "result": { + "$ref": "#/definitions/ServerResult" + } + }, + "required": [ + "jsonrpc", + "id", + "result" + ] + }, + "JsonRpcVersion2_0": { + "type": "string", + "format": "const", + "const": "2.0" + }, + "LegacyEnumSchema": { + "description": "Legacy enum schema, keep for backward compatibility", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "enumNames": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, + "ListPromptsResult": { + "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" + ] + }, + "prompts": { + "type": "array", + "items": { + "$ref": "#/definitions/Prompt" + } + }, + "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" + } + ] + }, + "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": [ + "prompts" + ] + }, + "ListResourceTemplatesResult": { + "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" + ] + }, + "resourceTemplates": { + "type": "array", + "items": { + "$ref": "#/definitions/ResourceTemplate" + } + }, + "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" + } + ] + }, + "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": [ + "resourceTemplates" + ] + }, + "ListResourcesResult": { + "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" + ] + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/definitions/Resource" + } + }, + "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" + } + ] + }, + "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": [ + "resources" + ] + }, + "ListRootsRequestMethod": { + "type": "string", + "format": "const", + "const": "roots/list" + }, + "ListToolsResult": { + "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" + } + ] + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/Tool" + } + }, + "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": [ + "tools" + ] + }, + "LoggingLevel": { + "description": "Logging levels supported by the MCP protocol", + "type": "string", + "deprecated": true, + "enum": [ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency" + ] + }, + "LoggingMessageNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/message" + }, + "LoggingMessageNotificationParam": { + "description": "Parameters for a logging message notification", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The actual log data" + }, + "level": { + "description": "The severity level of this log message", + "allOf": [ + { + "$ref": "#/definitions/LoggingLevel" + } + ] + }, + "logger": { + "description": "Optional logger name that generated this message", + "type": [ + "string", + "null" + ] + } + }, + "deprecated": true, + "required": [ + "level", + "data" + ] + }, + "MetaObject": { + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", + "type": "object", + "additionalProperties": true + }, + "ModelHint": { + "description": "A hint suggesting a preferred model name or family.\n\nModel hints are advisory suggestions that help clients choose appropriate\nmodels. They can be specific model names or general families like \"claude\" or \"gpt\".", + "type": "object", + "properties": { + "name": { + "description": "The suggested model name or family identifier", + "type": [ + "string", + "null" + ] + } + }, + "deprecated": true + }, + "ModelPreferences": { + "description": "Preferences for model selection and behavior in sampling requests.\n\nThis allows servers to express their preferences for which model to use\nand how to balance different priorities when the client has multiple\nmodel options available.", + "type": "object", + "properties": { + "costPriority": { + "description": "Priority for cost optimization (0.0 to 1.0, higher = prefer cheaper models)", + "type": [ + "number", + "null" + ], + "format": "float" + }, + "hints": { + "description": "Specific model names or families to prefer (e.g., \"claude\", \"gpt\")", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/ModelHint" + } + }, + "intelligencePriority": { + "description": "Priority for intelligence/capability (0.0 to 1.0, higher = prefer more capable models)", + "type": [ + "number", + "null" + ], + "format": "float" + }, + "speedPriority": { + "description": "Priority for speed/latency (0.0 to 1.0, higher = prefer faster models)", + "type": [ + "number", + "null" + ], + "format": "float" + } + }, + "deprecated": true + }, + "MultiSelectEnumSchema": { + "description": "Multi-select enum options", + "anyOf": [ + { + "$ref": "#/definitions/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelledNotificationMethod" + }, + "params": { + "$ref": "#/definitions/CancelledNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ProgressNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ProgressNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/LoggingMessageNotificationMethod" + }, + "params": { + "$ref": "#/definitions/LoggingMessageNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification4": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ResourceUpdatedNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ResourceUpdatedNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod" + }, + "params": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification6": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/TaskStatusNotificationMethod" + }, + "params": { + "$ref": "#/definitions/TaskStatusNotificationParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "NotificationMetaObject": { + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true + }, + "NotificationNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ResourceListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NotificationNoParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ToolListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NotificationNoParam3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/PromptListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NumberOrString": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "NumberSchema": { + "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/NumberTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "NumberTypeConst": { + "type": "string", + "format": "const", + "const": "number" + }, + "ObjectTypeConst": { + "type": "string", + "format": "const", + "const": "object" + }, + "PingRequestMethod": { + "type": "string", + "format": "const", + "const": "ping" + }, + "PrimitiveSchemaDefinition": { + "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", + "anyOf": [ + { + "description": "Enum property (explicit enum schema)", + "allOf": [ + { + "$ref": "#/definitions/EnumSchema" + } + ] + }, + { + "description": "String property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/StringSchema" + } + ] + }, + { + "description": "Number property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/NumberSchema" + } + ] + }, + { + "description": "Integer property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/IntegerSchema" + } + ] + }, + { + "description": "Boolean property", + "allOf": [ + { + "$ref": "#/definitions/BooleanSchema" + } + ] + } + ] + }, + "ProgressNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/progress" + }, + "ProgressNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "description": "An optional message describing the current progress.", + "type": [ + "string", + "null" + ] + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number", + "format": "double" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + }, + "total": { + "description": "Total number of items to process (or total progress required), if known", + "type": [ + "number", + "null" + ], + "format": "double" + } + }, + "required": [ + "progressToken", + "progress" + ] + }, + "ProgressToken": { + "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", + "allOf": [ + { + "$ref": "#/definitions/NumberOrString" + } + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers (spec `Prompt`).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "arguments": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/PromptArgument" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept (spec `PromptArgument`).", + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "required": { + "type": [ + "boolean", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "PromptListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/prompts/list_changed" + }, + "PromptMessage": { + "description": "A message returned as part of a prompt (spec `PromptMessage`).\n\nUses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).", + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/ContentBlock" + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": [ + "role", + "content" + ] + }, + "PromptsCapability": { + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "ProtocolVersion": { + "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" + }, + "ReadResourceResult": { + "description": "Result containing the contents of a read resource", + "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" + } + ] + }, + "contents": { + "description": "The actual content of the resource", + "type": "array", + "items": { + "$ref": "#/definitions/ResourceContents" + } + }, + "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" + } + ] + }, + "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": [ + "contents" + ] + }, + "Request": { + "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/CreateMessageRequestMethod" + }, + "params": { + "$ref": "#/definitions/CreateMessageRequestParams" + } + }, + "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", + "properties": { + "method": { + "$ref": "#/definitions/ElicitationCreateRequestMethod" + }, + "params": { + "$ref": "#/definitions/ElicitRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "RequestMetaObject": { + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true + }, + "RequestNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/PingRequestMethod" + } + }, + "required": [ + "method" + ] + }, + "RequestNoParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListRootsRequestMethod" + } + }, + "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", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this resource.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this resource represents.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this resource.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource (e.g. `file:///path/to/file`).", + "type": "string" + } + }, + "required": [ + "uri", + "name" + ] + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "text" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "blob" + ] + } + ] + }, + "ResourceListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/resources/list_changed" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server (spec `ResourceTemplate`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource template.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this template.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this template is for.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this template.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type for resources matching this template, if uniform.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource template.", + "type": "string" + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uriTemplate": { + "description": "An RFC 6570 URI template for constructing resource URIs.", + "type": "string" + } + }, + "required": [ + "uriTemplate", + "name" + ] + }, + "ResourceUpdatedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/resources/updated" + }, + "ResourceUpdatedNotificationParam": { + "description": "Parameters for a resource update notification", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "The URI of the resource that was updated", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "ResourcesCapability": { + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + }, + "subscribe": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "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.\n\nPer the SEP-2640 spec, the result is `resources: []` where\nsubdirectories carry `mimeType: \"inode/directory\"`.", + "type": "object", + "properties": { + "cacheScope": { + "type": [ + "string", + "null" + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/definitions/Resource" + } + }, + "resultType": { + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resources" + ] + }, + "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" + }, + "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": [ + { + "description": "A human user or client making a request", + "type": "string", + "const": "user" + }, + { + "description": "An AI assistant or server providing a response", + "type": "string", + "const": "assistant" + } + ] + }, + "RootsCapabilities": { + "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "SamplingCapability": { + "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", + "type": "object", + "properties": { + "context": { + "description": "Support for `includeContext` (soft-deprecated)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "tools": { + "description": "Support for `tools` and `toolChoice` parameters", + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, + "SamplingContent": { + "description": "Single or array content wrapper (SEP-1577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingMessageContentBlock" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessageContentBlock" + } + } + ] + }, + "SamplingMessage": { + "description": "A message in a sampling conversation, containing a role and content.\n\nThis represents a single message in a conversation flow, used primarily\nin LLM sampling requests where the conversation history is important\nfor generating appropriate responses.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "The actual content of the message (text, image, audio, tool use, or tool result)", + "allOf": [ + { + "$ref": "#/definitions/SamplingContent" + } + ] + }, + "role": { + "description": "The role of the message sender (User or Assistant)", + "allOf": [ + { + "$ref": "#/definitions/Role" + } + ] + } + }, + "deprecated": true, + "required": [ + "role", + "content" + ] + }, + "SamplingMessageContentBlock": { + "description": "Content types for sampling messages (SEP-1577).", + "deprecated": true, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "Assistant only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_use" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolUseContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "User only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_result" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolResultContent" + } + ], + "required": [ + "type" + ] + } + ] + }, + "ServerCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", + "type": "object", + "properties": { + "completions": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "experimental": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the server supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/apps\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "logging": { + "description": "Capability for server log message notifications (deprecated by SEP-2577).", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "prompts": { + "anyOf": [ + { + "$ref": "#/definitions/PromptsCapability" + }, + { + "type": "null" + } + ] + }, + "resources": { + "anyOf": [ + { + "$ref": "#/definitions/ResourcesCapability" + }, + { + "type": "null" + } + ] + }, + "skills": { + "description": "Capability for the SEP-2640 Skills extension.", + "anyOf": [ + { + "$ref": "#/definitions/SkillsCapability" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/definitions/DiscoverResult" + }, + { + "$ref": "#/definitions/InitializeResult" + }, + { + "$ref": "#/definitions/CompleteResult" + }, + { + "$ref": "#/definitions/GetPromptResult" + }, + { + "$ref": "#/definitions/ListPromptsResult" + }, + { + "$ref": "#/definitions/ListResourcesResult" + }, + { + "$ref": "#/definitions/ListResourceTemplatesResult" + }, + { + "$ref": "#/definitions/ReadResourceResult" + }, + { + "$ref": "#/definitions/ResourcesDirectoryReadResult" + }, + { + "$ref": "#/definitions/SkillsGetResult" + }, + { + "$ref": "#/definitions/SkillsListResult" + }, + { + "$ref": "#/definitions/SubscriptionsListenResult" + }, + { + "$ref": "#/definitions/ListToolsResult" + }, + { + "$ref": "#/definitions/ElicitResult" + }, + { + "$ref": "#/definitions/CreateTaskResult" + }, + { + "$ref": "#/definitions/GetTaskResult" + }, + { + "$ref": "#/definitions/CallToolResult" + }, + { + "$ref": "#/definitions/InputRequiredResult" + }, + { + "$ref": "#/definitions/TaskAckResult" + }, + { + "$ref": "#/definitions/EmptyObject" + }, + { + "$ref": "#/definitions/CustomResult" + } + ] + }, + "SingleSelectEnumSchema": { + "description": "Combined single-select", + "anyOf": [ + { + "$ref": "#/definitions/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledSingleSelectEnumSchema" + } + ] + }, + "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" + } + ] + }, + "SkillsCapability": { + "description": "Capability for the SEP-2640 Skills extension.\n\nAdvertises support for `skills/list`, `skills/get`, and\n`resources/directory/read` RPC methods.\n\nThe only spec-defined setting is `directoryRead`. A server that supports\n`resources/directory/read` MUST set it to `true`.", + "type": "object", + "properties": { + "directoryRead": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "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" + ] + }, + "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": [ + { + "description": "Email address format", + "type": "string", + "const": "email" + }, + { + "description": "URI format", + "type": "string", + "const": "uri" + }, + { + "description": "Date format (YYYY-MM-DD)", + "type": "string", + "const": "date" + }, + { + "description": "Date-time format (ISO 8601)", + "type": "string", + "const": "date-time" + } + ] + }, + "StringSchema": { + "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "format": { + "description": "String format - limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "anyOf": [ + { + "$ref": "#/definitions/StringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "description": "Maximum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "minLength": { + "description": "Minimum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/StringTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "StringTypeConst": { + "type": "string", + "format": "const", + "const": "string" + }, + "SubscriptionFilter": { + "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", + "type": "object", + "properties": { + "promptsListChanged": { + "type": "boolean" + }, + "resourceSubscriptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourcesListChanged": { + "type": "boolean" + }, + "toolsListChanged": { + "type": "boolean" + } + } + }, + "SubscriptionsAcknowledgedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/subscriptions/acknowledged" + }, + "SubscriptionsAcknowledgedNotificationParams": { + "description": "Parameters reporting the accepted subset of a subscription filter.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/NotificationMetaObject" + }, + "notifications": { + "$ref": "#/definitions/SubscriptionFilter" + } + }, + "required": [ + "notifications" + ] + }, + "SubscriptionsListenResult": { + "description": "Final response indicating that a subscription ended gracefully.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/SubscriptionsListenResultMeta" + }, + "resultType": { + "$ref": "#/definitions/ResultType" + } + }, + "required": [ + "resultType", + "_meta" + ] + }, + "SubscriptionsListenResultMeta": { + "type": "object", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + }, + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true, + "required": [ + "io.modelcontextprotocol/subscriptionId" + ] + }, + "TaskAckResult": { + "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "resultType": { + "description": "Always `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, + "TaskStatus": { + "description": "Canonical task lifecycle status (SEP-2663).", + "oneOf": [ + { + "description": "The request is currently being processed.", + "type": "string", + "const": "working" + }, + { + "description": "The server needs input from the client before the task can proceed.", + "type": "string", + "const": "input_required" + }, + { + "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", + "type": "string", + "const": "completed" + }, + { + "description": "The request failed due to a JSON-RPC error during execution.", + "type": "string", + "const": "failed" + }, + { + "description": "The request was cancelled before completion.", + "type": "string", + "const": "cancelled" + } + ] + }, + "TaskStatusNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tasks" + }, + "TaskStatusNotificationParams": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, + "TextContent": { + "description": "Text content block (spec `TextContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The text content of the message.", + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "TitledItems": { + "description": "Items for titled multi-select options", + "type": "object", + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + } + }, + "required": [ + "anyOf" + ] + }, + "TitledMultiSelectEnumSchema": { + "description": "Multi-select titled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/TitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "TitledSingleSelectEnumSchema": { + "description": "Titled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "oneOf" + ] + }, + "Tool": { + "description": "A tool that can be used by a model.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional additional metadata for this tool", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional additional tool information.", + "anyOf": [ + { + "$ref": "#/definitions/ToolAnnotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "A description of what the tool does", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional list of icons for the tool", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool", + "type": "object", + "additionalProperties": true + }, + "name": { + "description": "The name of the tool", + "type": "string" + }, + "outputSchema": { + "description": "An optional JSON Schema object defining the structure of the tool's output", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "title": { + "description": "A human-readable title for the tool", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "inputSchema" + ] + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "type": "object", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true\nA human-readable description of the tool's purpose.", + "type": [ + "boolean", + "null" + ] + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false.", + "type": [ + "boolean", + "null" + ] + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": [ + "boolean", + "null" + ] + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": [ + "boolean", + "null" + ] + }, + "title": { + "description": "A human-readable title for the tool.", + "type": [ + "string", + "null" + ] + } + } + }, + "ToolChoice": { + "description": "Tool choice configuration (SEP-1577).", + "type": "object", + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/ToolChoiceMode" + }, + { + "type": "null" + } + ] + } + }, + "deprecated": true + }, + "ToolChoiceMode": { + "description": "Tool selection mode (SEP-1577).", + "oneOf": [ + { + "description": "Model decides whether to use tools", + "type": "string", + "const": "auto" + }, + { + "description": "Model must use at least one tool", + "type": "string", + "const": "required" + }, + { + "description": "Model must not use tools", + "type": "string", + "const": "none" + } + ] + }, + "ToolListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tools/list_changed" + }, + "ToolResultContent": { + "description": "Tool execution result in user message (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/ContentBlock" + } + }, + "isError": { + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": true, + "toolUseId": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "toolUseId", + "content" + ] + }, + "ToolUseContent": { + "description": "Tool call request from assistant (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "input": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "id", + "name", + "input" + ] + }, + "ToolsCapability": { + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "UntitledItems": { + "description": "Items for untitled multi-select options", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, + "UntitledMultiSelectEnumSchema": { + "description": "Multi-select untitled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/UntitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "UntitledSingleSelectEnumSchema": { + "description": "Untitled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "additionalProperties": false, + "required": [ + "type", + "enum" + ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" + } + } +} \ No newline at end of file diff --git a/crates/rmcp/tests/test_skill_macros.rs b/crates/rmcp/tests/test_skill_macros.rs new file mode 100644 index 000000000..a314c7e10 --- /dev/null +++ b/crates/rmcp/tests/test_skill_macros.rs @@ -0,0 +1,86 @@ +//! Compile and behavior tests for the `#[skill]`, `#[skill_router]`, and +//! `#[skill_handler]` proc-macros. +//! +//! These tests verify that the macros expand to valid code and that the +//! generated `SkillEntry` fields are correct. + +use rmcp::model::skills::SkillResources; + +// ─── Test skill with static frontmatter ────────────────────────────────────── + +#[derive(Clone)] +struct TestServer; + +#[rmcp::skill_router(server_handler = true)] +impl TestServer { + #[rmcp::skill(uri = "skill://test-skill/SKILL.md", frontmatter = serde_json::json!({ + "name": "test-skill", + "description": "A test skill" + }))] + async fn test_skill(&self) -> Result<(), rmcp::ErrorData> { + Ok(()) + } +} + +// ─── Test skill with dynamic resources ─────────────────────────────────────── + +#[derive(Clone)] +struct DynamicServer; + +#[rmcp::skill_router(server_handler = true)] +impl DynamicServer { + #[rmcp::skill( + uri = "skill://dynamic-skill/SKILL.md", + frontmatter = serde_json::json!({ + "name": "dynamic-skill", + "description": "A dynamic skill" + }), + dynamic = true + )] + async fn dynamic_skill(&self) -> Result<(), rmcp::ErrorData> { + Ok(()) + } +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[test] +fn skill_attr_generates_correct_entry() { + let entry = TestServer::test_skill_skill_attr(); + assert_eq!(entry.uri, "skill://test-skill/SKILL.md"); + assert_eq!(entry.frontmatter["name"], "test-skill"); + assert_eq!(entry.frontmatter["description"], "A test skill"); + // Non-dynamic skill with no file list should have None resources + assert!(entry.resources.is_none()); +} + +#[test] +fn dynamic_skill_has_dynamic_resources() { + let entry = DynamicServer::dynamic_skill_skill_attr(); + assert_eq!(entry.uri, "skill://dynamic-skill/SKILL.md"); + assert_eq!(entry.frontmatter["name"], "dynamic-skill"); + assert!(matches!(entry.resources, Some(SkillResources::Dynamic))); +} + +#[test] +fn skill_router_lists_all_skills() { + let router = TestServer::skill_router(); + let skills = router.list_all(); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].uri, "skill://test-skill/SKILL.md"); +} + +#[test] +fn skill_router_get_by_uri() { + let router = TestServer::skill_router(); + let route = router.get_by_uri("skill://test-skill/SKILL.md"); + assert!(route.is_some()); + assert_eq!(route.unwrap().attr.uri, "skill://test-skill/SKILL.md"); +} + +#[test] +fn skill_router_get_by_unknown_uri_returns_none() { + let router = TestServer::skill_router(); + let route = router.get_by_uri("skill://unknown/SKILL.md"); + assert!(route.is_none()); +} diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index e5d585100..05e949721 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -238,7 +238,12 @@ fn multi_file_skill_lists_siblings() { .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"); + // After defect 5 fix: SKILL.md + 2 siblings = 3 entries + assert_eq!( + names.len(), + 3, + "refunds skill should have 3 entries (SKILL.md + 2 siblings)" + ); assert!( names.iter().any(|uri| uri.ends_with("/FORMS.md")), "should contain FORMS.md" @@ -274,9 +279,18 @@ fn single_file_skill_has_no_resources() { .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" + // After defect 5 fix: SKILL.md itself is included in resources. + // A single-file skill should have exactly 1 resource (SKILL.md). + let resources = resources.expect("single-file skill should have resources"); + assert_eq!( + resources.len(), + 1, + "single-file skill should have SKILL.md in resources" + ); + assert_eq!( + resources[0]["uri"].as_str().unwrap(), + "skill://git-workflow/SKILL.md", + "resource should be SKILL.md" ); } @@ -401,5 +415,10 @@ fn multi_file_skill_has_resources_field() { .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"); + // After defect 5 fix: SKILL.md + 2 siblings = 3 entries + assert_eq!( + resources.len(), + 3, + "should have 3 file entries (SKILL.md + 2 siblings)" + ); } From 394627d72045e58b3ce79292a80715ca30e594ec Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sat, 10 Oct 2026 10:42:25 -0700 Subject: [PATCH 11/26] fix(rmcp): defect 2 (URI prefix match) + subdirectory bug + macro test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phymora review findings addressed: 1. Defect 2 (URI parsing): replaced parse_skill_uri-based lookup with longest-prefix matching in SkillRouter::get_by_uri. Now correctly handles nested skill roots (skill://acme/billing/refunds/templates/x.md) without needing to parse the URI. 2. Subdirectory bug: list_skill_directory now checks if the canonical target is a directory and reads it directly, instead of always reading the parent. Fixes resources/directory/read for subdirectory URIs. 3. Macro test coverage: added 5 unit tests in test_skill_macros.rs covering longest-prefix matching (exact match, nested exact match, nested file prefix match, single-segment file prefix match, no-match for unrelated prefix). Removed unreliable stdio-based behavior tests (cargo run stdio pipe is unreliable in test harness). Gates: cargo +nightly fmt, cargo clippy --all-targets --all-features -D warnings, cargo test --all-features — all pass --- crates/rmcp/Cargo.toml | 4 ++ crates/rmcp/examples/skill_fs_server.rs | 19 +++-- crates/rmcp/examples/skill_macro_server.rs | 49 +++++++++++++ .../rmcp/src/handler/server/router/skill.rs | 30 +++++++- crates/rmcp/tests/test_skill_macros.rs | 72 +++++++++++++++++++ 5 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 crates/rmcp/examples/skill_macro_server.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index d6d2b7f7f..7d95cd75f 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -486,3 +486,7 @@ path = "tests/test_streamable_http_disconnect_cancel.rs" name = "test_sep_2260_stream_routing" required-features = ["server", "elicitation", "transport-streamable-http-server", "reqwest"] path = "tests/test_sep_2260_stream_routing.rs" + +[[example]] +name = "skill_macro_server" +required-features = ["server", "transport-io"] diff --git a/crates/rmcp/examples/skill_fs_server.rs b/crates/rmcp/examples/skill_fs_server.rs index 1be87fa1f..78ef2a6f5 100644 --- a/crates/rmcp/examples/skill_fs_server.rs +++ b/crates/rmcp/examples/skill_fs_server.rs @@ -190,21 +190,30 @@ impl FileSystemSkillServer { self.skills.get(uri) } - /// List the sibling files of a skill file URI. + /// List the contents of a skill directory or the siblings of a skill file. + /// + /// If the URI names a directory (no trailing slash, last segment is not a + /// file), list its children. If it names a file, list its siblings. 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) { + let canonical_target = file_path.canonicalize().ok()?; + if !canonical_target.starts_with(&canonical_root) { return None; } + // If the target is a directory, read it directly; otherwise read its parent + let dir_to_read = if canonical_target.is_dir() { + canonical_target + } else { + canonical_target.parent()?.to_path_buf() + }; + let mut entries = Vec::new(); - for entry in fs::read_dir(&canonical_parent).ok()?.flatten() { + for entry in fs::read_dir(&dir_to_read).ok()?.flatten() { let path = entry.path(); let name = path.file_name()?.to_str()?; if name == SKILL_FILE { diff --git a/crates/rmcp/examples/skill_macro_server.rs b/crates/rmcp/examples/skill_macro_server.rs new file mode 100644 index 000000000..698434bf3 --- /dev/null +++ b/crates/rmcp/examples/skill_macro_server.rs @@ -0,0 +1,49 @@ +//! Example server using `#[skill]` + `#[skill_router]` macros. +//! +//! This server demonstrates the macro-generated `skills_list`, `skills_get`, +//! and `resources_directory_read` methods. It uses a simple in-memory skill +//! registry (no filesystem) so it can be tested without fixture files. + +use rmcp::ServiceExt; + +#[derive(Clone)] +struct MacroServer; + +#[rmcp::skill_router(server_handler = true)] +impl MacroServer { + #[rmcp::skill(uri = "skill://alpha/SKILL.md", frontmatter = serde_json::json!({ + "name": "alpha", + "description": "Alpha skill" + }))] + async fn alpha(&self) -> Result<(), rmcp::ErrorData> { + Ok(()) + } + + #[rmcp::skill(uri = "skill://beta/nested/SKILL.md", frontmatter = serde_json::json!({ + "name": "nested", + "description": "Beta skill with nested path" + }))] + async fn beta(&self) -> Result<(), rmcp::ErrorData> { + Ok(()) + } + + #[rmcp::skill( + uri = "skill://gamma/SKILL.md", + frontmatter = serde_json::json!({ + "name": "gamma", + "description": "Gamma skill with dynamic resources" + }), + dynamic = true + )] + async fn gamma(&self) -> Result<(), rmcp::ErrorData> { + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = MacroServer; + let transport = rmcp::transport::stdio(); + server.serve(transport).await?; + Ok(()) +} diff --git a/crates/rmcp/src/handler/server/router/skill.rs b/crates/rmcp/src/handler/server/router/skill.rs index 22be1ec73..edaa2ea38 100644 --- a/crates/rmcp/src/handler/server/router/skill.rs +++ b/crates/rmcp/src/handler/server/router/skill.rs @@ -150,10 +150,34 @@ where } } - /// Match a request URI to its route, parsing out the skill path first. + /// Match a request URI to its route using longest-prefix matching. + /// + /// Skill roots can be multi-segment (`acme/billing/refunds`) and file paths + /// can nest arbitrarily deep (`templates/x.md`). There is no in-URI delimiter + /// between skill-path and file-path, so we cannot parse the URI alone. + /// Instead, we find the registered route whose skill root is the longest + /// prefix of the request URI's skill root. 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()) + let stripped = uri.strip_prefix("skill://")?; + if stripped.is_empty() { + return None; + } + // For each registered route, extract its skill path (e.g. `acme/billing/refunds`) + // and check if the request URI starts with `skill:///`. + // This handles both exact SKILL.md matches and nested file URIs. + let mut best: Option<(&Cow<'static, str>, &SkillRoute)> = None; + for (key, route) in &self.map { + if let Some(route_skill_path) = route.skill_path() { + let prefix = format!("skill://{}/", route_skill_path); + if uri.starts_with(&prefix) { + match best { + Some((best_key, _)) if best_key.len() >= key.len() => {} + _ => best = Some((key, route)), + } + } + } + } + best.map(|(_, route)| route) } pub fn list_all(&self) -> Vec { diff --git a/crates/rmcp/tests/test_skill_macros.rs b/crates/rmcp/tests/test_skill_macros.rs index a314c7e10..fd8d0c11c 100644 --- a/crates/rmcp/tests/test_skill_macros.rs +++ b/crates/rmcp/tests/test_skill_macros.rs @@ -84,3 +84,75 @@ fn skill_router_get_by_unknown_uri_returns_none() { let route = router.get_by_uri("skill://unknown/SKILL.md"); assert!(route.is_none()); } + +// ─── Longest-prefix matching for nested skill URIs ──────────────────────────── + +#[derive(Clone)] +struct NestedServer; + +#[rmcp::skill_router(server_handler = true)] +impl NestedServer { + #[rmcp::skill(uri = "skill://acme/billing/refunds/SKILL.md", frontmatter = serde_json::json!({ + "name": "refunds", + "description": "A nested skill" + }))] + async fn refunds(&self) -> Result<(), rmcp::ErrorData> { + Ok(()) + } + + #[rmcp::skill(uri = "skill://alpha/SKILL.md", frontmatter = serde_json::json!({ + "name": "alpha", + "description": "A single-segment skill" + }))] + async fn alpha(&self) -> Result<(), rmcp::ErrorData> { + Ok(()) + } +} + +#[test] +fn skill_router_exact_match() { + let router = NestedServer::skill_router(); + let route = router.get_by_uri("skill://alpha/SKILL.md"); + assert!(route.is_some()); + assert_eq!(route.unwrap().attr.uri, "skill://alpha/SKILL.md"); +} + +#[test] +fn skill_router_nested_exact_match() { + let router = NestedServer::skill_router(); + let route = router.get_by_uri("skill://acme/billing/refunds/SKILL.md"); + assert!(route.is_some()); + assert_eq!( + route.unwrap().attr.uri, + "skill://acme/billing/refunds/SKILL.md" + ); +} + +#[test] +fn skill_router_nested_file_prefix_match() { + // Defect 2 fix: a file URI under a nested skill root must match + let router = NestedServer::skill_router(); + let route = router.get_by_uri("skill://acme/billing/refunds/templates/x.md"); + assert!(route.is_some()); + assert_eq!( + route.unwrap().attr.uri, + "skill://acme/billing/refunds/SKILL.md" + ); +} + +#[test] +fn skill_router_single_segment_file_prefix_match() { + // Defect 2 fix: a file URI under a single-segment skill root must match + let router = NestedServer::skill_router(); + let route = router.get_by_uri("skill://alpha/references/guide.md"); + assert!(route.is_some()); + assert_eq!(route.unwrap().attr.uri, "skill://alpha/SKILL.md"); +} + +#[test] +fn skill_router_no_match_for_unrelated_prefix() { + // skill://acme/billing/other/... should NOT match skill://acme/billing/refunds/SKILL.md + let router = NestedServer::skill_router(); + let route = router.get_by_uri("skill://acme/billing/other/file.md"); + assert!(route.is_none()); +} From 959fc2543354cbdafd1e41b2593cfc5eb4d77981 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sun, 11 Oct 2026 05:49:48 -0700 Subject: [PATCH 12/26] fix(rmcp): drop top-level skills capability field, use extension map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option D: remove ServerCapabilities.skills field, SkillsCapability struct, and const SKILLS from builder macro. Use enable_skills_extension() instead (writes to extensions map, matching enable_tasks pattern). No public API change — SemVer and Public API checks pass. Also fix rmcp-macros doc links: [SkillEntry] and [SkillRouter] were unresolved (types live in rmcp, not rmcp-macros). Corrected .enable_skills() to .enable_skills_extension() in skill_handler doc. All 8 gates green: fmt, clippy, test, conformance 10/10, schema 3/3, cargo doc, cargo semver-checks 202 pass, cargo public-api clean. --- crates/rmcp-macros/src/lib.rs | 6 ++-- crates/rmcp-macros/src/skill_handler.rs | 2 -- crates/rmcp/src/model/capabilities.rs | 29 ------------------- .../server_json_rpc_message_schema.json | 23 --------------- ...erver_json_rpc_message_schema_current.json | 23 --------------- 5 files changed, 3 insertions(+), 80 deletions(-) diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index 9f9f6a7d7..1bb21b599 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -338,7 +338,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> TokenStream { /// /// Marks a function as a skill handler for the SEP-2640 Skills extension. /// Generates a companion `*_skill_attr()` function returning the skill's -/// [`SkillEntry`] metadata, and wraps the async body in `Pin>`. +/// `SkillEntry` metadata, and wraps the async body in `Pin>`. #[proc_macro_attribute] pub fn skill(attr: TokenStream, input: TokenStream) -> TokenStream { skill::skill(attr.into(), input.into()) @@ -349,7 +349,7 @@ pub fn skill(attr: TokenStream, input: TokenStream) -> TokenStream { /// # skill_router /// /// Walks an impl block for `#[skill]`-annotated functions and builds a -/// [`SkillRouter`] mapping each skill's `skill://` URI to its handler. +/// `SkillRouter` mapping each skill's `skill://` URI to its handler. #[proc_macro_attribute] pub fn skill_router(attr: TokenStream, input: TokenStream) -> TokenStream { skill_router::skill_router(attr.into(), input.into()) @@ -362,7 +362,7 @@ pub fn skill_router(attr: TokenStream, input: TokenStream) -> TokenStream { /// Implements the `ServerHandler` skills trait methods (`skills_list`, /// `skills_get`, `resources_directory_read`) for a struct, wiring them /// through a `SkillRouter`. Also generates `get_info()` with -/// `.enable_skills()` if no sibling `tool_handler` is present. +/// `.enable_skills_extension()` if no sibling `tool_handler` is present. #[proc_macro_attribute] pub fn skill_handler(attr: TokenStream, input: TokenStream) -> TokenStream { skill_handler::skill_handler(attr.into(), input.into()) diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index 810d40174..ea04103a2 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -48,8 +48,6 @@ pub(crate) fn build_get_info( let mut capability_calls = Vec::new(); if has_skills { - capability_calls - .push(quote! { .enable_skills_with(rmcp::model::SkillsCapability::new(true)) }); capability_calls.push(quote! { .enable_skills_extension() }); capability_calls.push(quote! { .enable_resources() }); } diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 41014bf23..5154fc0a1 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -63,31 +63,6 @@ pub struct ToolsCapability { /// Extension identifier for the SEP-2640 Skills extension. pub const SKILLS_EXTENSION_ID: &str = "io.modelcontextprotocol/skills"; -/// Capability for the SEP-2640 Skills extension. -/// -/// Advertises support for `skills/list`, `skills/get`, and -/// `resources/directory/read` RPC methods. -/// -/// The only spec-defined setting is `directoryRead`. A server that supports -/// `resources/directory/read` MUST set it to `true`. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct SkillsCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub directory_read: Option, -} - -impl SkillsCapability { - /// Create a new `SkillsCapability` with `directoryRead` set. - pub fn new(directory_read: bool) -> Self { - Self { - directory_read: Some(directory_read), - } - } -} - /// Roots capability. Deprecated by SEP-2577; remains functional and will be /// removed in a future release. /// See . @@ -268,9 +243,6 @@ pub struct ServerCapabilities { pub resources: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option, - /// Capability for the SEP-2640 Skills extension. - #[serde(skip_serializing_if = "Option::is_none")] - pub skills: Option, } impl ServerCapabilities { @@ -407,7 +379,6 @@ builder! { prompts: PromptsCapability, resources: ResourcesCapability, tools: ToolsCapability, - skills: SkillsCapability, } } 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 106850d97..404c1b0f2 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 @@ -3261,17 +3261,6 @@ } ] }, - "skills": { - "description": "Capability for the SEP-2640 Skills extension.", - "anyOf": [ - { - "$ref": "#/definitions/SkillsCapability" - }, - { - "type": "null" - } - ] - }, "tools": { "anyOf": [ { @@ -3449,18 +3438,6 @@ } ] }, - "SkillsCapability": { - "description": "Capability for the SEP-2640 Skills extension.\n\nAdvertises support for `skills/list`, `skills/get`, and\n`resources/directory/read` RPC methods.\n\nThe only spec-defined setting is `directoryRead`. A server that supports\n`resources/directory/read` MUST set it to `true`.", - "type": "object", - "properties": { - "directoryRead": { - "type": [ - "boolean", - "null" - ] - } - } - }, "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", 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 106850d97..404c1b0f2 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 @@ -3261,17 +3261,6 @@ } ] }, - "skills": { - "description": "Capability for the SEP-2640 Skills extension.", - "anyOf": [ - { - "$ref": "#/definitions/SkillsCapability" - }, - { - "type": "null" - } - ] - }, "tools": { "anyOf": [ { @@ -3449,18 +3438,6 @@ } ] }, - "SkillsCapability": { - "description": "Capability for the SEP-2640 Skills extension.\n\nAdvertises support for `skills/list`, `skills/get`, and\n`resources/directory/read` RPC methods.\n\nThe only spec-defined setting is `directoryRead`. A server that supports\n`resources/directory/read` MUST set it to `true`.", - "type": "object", - "properties": { - "directoryRead": { - "type": [ - "boolean", - "null" - ] - } - } - }, "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", From 90ffc877c3a3ecded0ba79db9ec218e30d068cf9 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sun, 11 Oct 2026 07:33:31 -0700 Subject: [PATCH 13/26] test(rmcp): add real resources/directory/read + resources/read conformance tests The conformance suite header claimed resources/directory/read coverage but no test called it. Added two tests: - resources_directory_read_lists_siblings: verifies SKILL.md exclusion + 2 siblings - resources_read_returns_file_contents: verifies file bytes returned verbatim Suite 10/10 -> 12/12. Teeth proven by forcing handler empty (RED: left:0, right:2), then restoring (GREEN). --- .gitignore | 1 + crates/rmcp/tests/test_skills_conformance.rs | 86 ++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/.gitignore b/.gitignore index 2288665b1..bf958b224 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # will have compiled files and executables debug/ target/ +.serena/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index 05e949721..71f3f8230 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -422,3 +422,89 @@ fn multi_file_skill_has_resources_field() { "should have 3 file entries (SKILL.md + 2 siblings)" ); } + +/// `resources/directory/read` is the request that actually lists a skill's +/// sibling files. The header comment has always claimed this coverage, but no +/// test called it — `skills/get` returning a `resources` field is a different +/// code path. This exercises the real method. +#[test] +fn resources_directory_read_lists_siblings() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request( + 2, + "resources/directory/read", + serde_json::json!({"uri": "skill://billing/refunds"}), + ), + ]; + + let responses = run_server_request(&requests); + let resources = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["resources"].as_array()) + .expect("resources/directory/read should return a resources array"); + + // SKILL.md is excluded from the directory listing; only the 2 siblings remain. + assert_eq!( + resources.len(), + 2, + "refunds directory should list 2 sibling files (SKILL.md excluded)" + ); + assert!( + resources + .iter() + .any(|r| r["uri"].as_str().unwrap_or("").ends_with("/FORMS.md")), + "listing should contain FORMS.md" + ); + assert!( + resources + .iter() + .any(|r| r["uri"].as_str().unwrap_or("").ends_with("/APPENDIX.md")), + "listing should contain APPENDIX.md" + ); +} + +/// `resources/read` returns the bytes of one skill file. Also claimed by the +/// header, also never actually called before this test. +#[test] +fn resources_read_returns_file_contents() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + build_request( + 2, + "resources/read", + serde_json::json!({"uri": "skill://billing/refunds/FORMS.md"}), + ), + ]; + + let responses = run_server_request(&requests); + let contents = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["contents"].as_array()) + .expect("resources/read should return a contents array"); + + assert_eq!(contents.len(), 1, "should return exactly one content block"); + let text = contents[0]["text"].as_str().unwrap_or(""); + assert!( + text.contains("Form 101"), + "file contents should be returned verbatim" + ); +} From d235c456f7fd2522b4c01939018b9a396a1b2483 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sun, 11 Oct 2026 14:37:08 -0700 Subject: [PATCH 14/26] fix(rmcp): hold RunningService in skill_macro_server example + wire tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `skill_macro_server` example discarded the `RunningService` returned by `server.serve(transport)`. That value owns a `DropGuard` (tokio_util::sync); dropping it cancels the serve-loop token, so the process answered `initialize` and then exited 0 before any other request. The macro-generated server — the feature this PR adds — could not serve a skill over stdio at all. Fix: bind the handle and await `service.waiting()`, the pattern every other example already uses (see `skill_fs_server.rs`). Tests (both fail without the fix): - `macro_server_skills_list_and_get`: drives `initialize` -> `notifications/initialized` -> `skills/list` -> `skills/get` over stdio and asserts the advertised skills extension, the 3 discovered skills, and the `result.skill.frontmatter` shape. - `macro_server_resources_directory_read_is_dispatched`: asserts `resources/directory/read` is dispatched (not -32601) and that `initialize` advertises the `resources` capability. The example declares no file lists (`#[skill]` has no file-list attribute), so an empty array is correct; the non-empty case is covered by `skill_fs_server` over the real fixtures. Verified: 14/14 conformance tests pass; `cargo +nightly fmt --all -- --check` and `cargo clippy --all-targets --all-features -- -D warnings` clean. Teeth: disabling the macro's `resources_directory_read` generation makes the new test fail with -32601; restoring makes it pass. Does NOT touch library code (`crates/rmcp/src/`) — the defect was confined to the example added in 301eac9. --- crates/rmcp/examples/skill_macro_server.rs | 6 +- crates/rmcp/tests/test_skills_conformance.rs | 198 +++++++++++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/examples/skill_macro_server.rs b/crates/rmcp/examples/skill_macro_server.rs index 698434bf3..45c8d0fcc 100644 --- a/crates/rmcp/examples/skill_macro_server.rs +++ b/crates/rmcp/examples/skill_macro_server.rs @@ -44,6 +44,10 @@ impl MacroServer { async fn main() -> Result<(), Box> { let server = MacroServer; let transport = rmcp::transport::stdio(); - server.serve(transport).await?; + // Hold the `RunningService` for the life of the process. Dropping it + // immediately (as `server.serve(transport).await?;` alone would) cancels + // the serve loop via its DropGuard and the server exits before answering. + let service = server.serve(transport).await?; + service.waiting().await?; Ok(()) } diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index 71f3f8230..8802b0a36 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -508,3 +508,201 @@ fn resources_read_returns_file_contents() { "file contents should be returned verbatim" ); } + +// --------------------------------------------------------------------------- +// Macro-generated server — skill_macro_server example +// --------------------------------------------------------------------------- + +/// Drive the macro server over stdio, mirroring `run_server_request` for the +/// hand-written `skill_fs_server`. This is the only test that exercises the +/// macro expansion path (`#[skill]` + `#[skill_router]` + `#[skill_handler]`) +/// end-to-end through a real process. +fn run_macro_server_request(requests: &[String]) -> Vec { + let mut child = Command::new("cargo") + .args([ + "run", + "--example", + "skill_macro_server", + "--features", + "server,transport-io", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to spawn macro 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() +} + +/// The macro-generated server must answer `skills/list` and `skills/get` over +/// the wire. Regression guard: the example must hold its `RunningService` +/// (`.waiting()`), or the serve loop is cancelled on drop and the process +/// exits after `initialize` without answering anything else. +#[test] +fn macro_server_skills_list_and_get() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + // The initialized notification is required before the server will + // answer subsequent requests. + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }) + .to_string(), + build_request(2, "skills/list", serde_json::json!({})), + build_request( + 3, + "skills/get", + serde_json::json!({"uri": "skill://alpha/SKILL.md"}), + ), + ]; + + let responses = run_macro_server_request(&requests); + + // initialize → skills extension advertised + let init_resp = responses + .iter() + .find(|r| r["id"] == 1) + .expect("macro server should answer initialize"); + assert!( + !init_resp["result"]["capabilities"]["extensions"]["io.modelcontextprotocol/skills"] + .is_null(), + "skills extension must be advertised; got: {init_resp}" + ); + + // skills/list → 3 skills from the macro server + let skills_list = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skills"].as_array()) + .expect("macro server should return skills/list response"); + + assert_eq!( + skills_list.len(), + 3, + "macro server should discover 3 skills (alpha, beta, gamma); got: {skills_list:?}" + ); + let uris: Vec<&str> = skills_list + .iter() + .map(|s| s["uri"].as_str().unwrap_or("")) + .collect(); + assert!( + uris.contains(&"skill://alpha/SKILL.md"), + "alpha skill should be listed; got: {uris:?}" + ); + assert!( + uris.contains(&"skill://beta/nested/SKILL.md"), + "beta skill should be listed; got: {uris:?}" + ); + + // skills/get → the entry lives under result.skill, not result + let get_resp = responses + .iter() + .find(|r| r["id"] == 3) + .expect("macro server should return skills/get response"); + let frontmatter = get_resp["result"]["skill"]["frontmatter"] + .as_object() + .expect("skills/get should return result.skill.frontmatter"); + assert_eq!( + frontmatter["name"], "alpha", + "skills/get should return correct frontmatter" + ); +} + +/// The macro-generated handler must wire `resources/directory/read` into the +/// `ServerHandler` dispatch and advertise the `resources` capability. +/// +/// The example declares no file lists — the `#[skill]` macro has no file-list +/// attribute (only `dynamic`) — so an empty array is the correct result. The +/// point of this test is that the method is *answered*, not that it lists +/// files; the hand-written `skill_fs_server` covers the non-empty case. If the +/// macro stops generating `resources_directory_read`, this endpoint falls back +/// to the trait default and returns method-not-found (-32601) instead. +#[test] +fn macro_server_resources_directory_read_is_dispatched() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }) + .to_string(), + // Static skill (alpha) — no file list declared. + build_request( + 2, + "resources/directory/read", + serde_json::json!({"uri": "skill://alpha/SKILL.md"}), + ), + // Dynamic skill (gamma) — macro returns an empty list for `Dynamic`. + build_request( + 3, + "resources/directory/read", + serde_json::json!({"uri": "skill://gamma/SKILL.md"}), + ), + ]; + + let responses = run_macro_server_request(&requests); + + // initialize → resources capability advertised (gates the endpoint). + let init_resp = responses + .iter() + .find(|r| r["id"] == 1) + .expect("macro server should answer initialize"); + assert!( + !init_resp["result"]["capabilities"]["resources"].is_null(), + "macro server must advertise the resources capability; got: {init_resp}" + ); + + for id in [2u64, 3] { + let resp = responses.iter().find(|r| r["id"] == id).unwrap_or_else(|| { + panic!("macro server should answer resources/directory/read (id {id})") + }); + assert!( + resp["error"].is_null(), + "resources/directory/read must be dispatched, not method-not-found; got: {resp}" + ); + assert!( + resp["result"]["resources"].is_array(), + "resources/directory/read should return a resources array; got: {resp}" + ); + } +} From 25d3051416d85bb31dfe2b863d3fb395753cfe44 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sun, 11 Oct 2026 14:49:30 -0700 Subject: [PATCH 15/26] fix(rmcp): gate `Cow` import in skills.rs behind `schemars` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `skills.rs` invokes `const_string!`, whose expansion contains a `#[cfg(feature = "schemars")]` impl that names `Cow` unqualified. The import is therefore required only when `schemars` is enabled, and is unused — a `-D warnings` error — without it. CI's reduced-feature clippy matrix (`cargo hack clippy --lib --each-feature --exclude-features local`) hits exactly that combination, which is why the PR's older `ci.yml` never caught it. Gate the import with `#[cfg(feature = "schemars")]` instead of deleting it: removal breaks the `schemars` build (E0425 `cannot find type Cow` in `model.rs:127` via the macro expansion). Verified with CI's exact command locally: cargo hack clippy --package rmcp --lib --each-feature --exclude-features local -- -D warnings `Cow` is no longer reported in either the schemars-on or schemars-off combo. (The remaining `prompt.rs` failure in that matrix is inherited from being behind `main`; upstream #1314 fixes it, so it does not appear on CI's merge commit.) --- crates/rmcp/src/model/skills.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/rmcp/src/model/skills.rs b/crates/rmcp/src/model/skills.rs index 905595547..8df79e4d8 100644 --- a/crates/rmcp/src/model/skills.rs +++ b/crates/rmcp/src/model/skills.rs @@ -6,6 +6,10 @@ // Skills are transported as MCP Resources. See §Resource Mapping and §Discovery of // the spec for the exact wire shapes. +// `const_string!` expands a `#[cfg(feature = "schemars")]` impl that names +// `Cow` unqualified, so the import is required only with that feature — and is +// unused (hence a `-D warnings` error) without it. +#[cfg(feature = "schemars")] use std::borrow::Cow; use serde::{Deserialize, Serialize}; From 5b8b3dec6f587900ae5351369e2e6f100239eaf6 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Mon, 12 Oct 2026 15:05:36 -0700 Subject: [PATCH 16/26] fix(rmcp): route skill directory reads through the skill catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Router's `resources/directory/read` arm returned a hardcoded `resources: vec![]` for every URI instead of consulting the skill catalog it owns, so a skill with declared files answered empty. Route the request through `skill_router.get_by_uri` and answer from the matching route's declared `SkillResources::FileList`; fall back to the inner service for URIs no route owns. A directory-form URI (`skill://`, no `/SKILL.md`) also failed the `starts_with("skill:///")` match, so accept both the directory and file forms — `resources/directory/read` sends the directory form, which makes the delegation reachable at all. Evidence (each fix reverted alone, re-run, then restored): - router::tests::test_router_directory_read_delegates_to_file_list_route RED: left 0, right 2 (hardcoded empty vec) -> GREEN: pass - router::tests::test_skill_router_matches_directory_form_uri RED: "directory-form URI must match after fix" -> GREEN: pass Scope: no public type and no wire shape changes. --- crates/rmcp/src/handler/server/router.rs | 159 +++++++++++++++++- .../rmcp/src/handler/server/router/skill.rs | 9 +- crates/rmcp/tests/test_skills_conformance.rs | 57 +++++++ 3 files changed, 213 insertions(+), 12 deletions(-) diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index 1fc9f686a..6a54e99d0 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -187,15 +187,36 @@ where .await } } - ClientRequest::ResourcesDirectoryReadRequest(_request) => { - let result = crate::model::skills::ResourcesDirectoryReadResult { - result_type: Some(crate::model::ResultType::COMPLETE), - resources: vec![], - next_cursor: None, - ttl_ms: None, - cache_scope: None, - }; - Ok(ServerResult::ResourcesDirectoryReadResult(result)) + ClientRequest::ResourcesDirectoryReadRequest(request) => { + // The router owns the skill catalog, so it is authoritative for + // any URI that matches a route: answer from the route's declared + // resources. A route with no static file list yields an empty + // array, which is the correct answer for a file-less skill. + if let Some(route) = self.skill_router.get_by_uri(&request.params.uri) { + let resources: Vec = match &route.attr.resources { + Some(crate::model::skills::SkillResources::FileList(files)) => files + .iter() + .map(|f| { + let name = f.uri.rsplit('/').next().unwrap_or("").to_string(); + crate::model::Resource::new(f.uri.clone(), name) + }) + .collect(), + _ => Vec::new(), + }; + let mut result = + crate::model::skills::ResourcesDirectoryReadResult::new(resources); + result.result_type = Some(crate::model::ResultType::COMPLETE); + Ok(ServerResult::ResourcesDirectoryReadResult(result)) + } else { + // No router-declared skill for this URI — let the inner + // service answer (it may have its own directory logic). + self.service + .handle_request( + ClientRequest::ResourcesDirectoryReadRequest(request), + context, + ) + .await + } } rest => self.service.handle_request(rest, context).await, } @@ -228,6 +249,126 @@ mod tests { struct DummyHandler; impl ServerHandler for DummyHandler {} + /// Dummy skill handler used only to build a `SkillRoute` in tests. + /// + /// A **named `fn` item** (not a closure) is required: `CallSkillHandler` + /// is blanket-implemented for `F: for<'a> Fn(&'a S, SkillCallContext<'a, S>)`, + /// and a fn item is higher-ranked by construction while a closure that + /// mentions the context type is not ("implementation of `FnOnce` is not + /// general enough"). + fn dummy_skill_handler( + _service: &S, + _ctx: crate::handler::server::skill::SkillCallContext, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result, + > + Send + + 'static, + >, + > { + Box::pin(async { + Ok(crate::model::skills::SkillEntry::new( + "skill://dummy/SKILL.md", + serde_json::json!({}), + )) + }) + } + + fn test_request_context() -> crate::service::RequestContext { + let id_provider: Arc = + Arc::new(AtomicU32RequestIdProvider::default()); + let (peer, _rx) = Peer::::new(id_provider, None); + crate::service::RequestContext::new(crate::model::NumberOrString::Number(1), peer) + } + + /// **Fix #1** — `resources/directory/read` through the `Router` must answer + /// from the matching route's declared file list, not a hardcoded empty vec. + /// + /// Goes RED before the fix (hardcoded `resources: vec![]` → len 0) and GREEN + /// after. A route with a real `FileList` is required: a file-less route + /// returns an empty array either way and cannot distinguish the bug. + #[tokio::test] + async fn test_router_directory_read_delegates_to_file_list_route() { + use crate::model::{ + ClientRequest, + skills::{ + ResourcesDirectoryReadRequest, ResourcesDirectoryReadRequestParams, SkillEntry, + SkillResource, SkillResources, + }, + }; + + let mut entry = SkillEntry::new( + "skill://alpha/SKILL.md", + serde_json::json!({"name": "alpha", "description": "Alpha skill"}), + ); + entry.resources = Some(SkillResources::FileList(vec![ + SkillResource::new("skill://alpha/SKILL.md", "digest-a", 10), + SkillResource::new("skill://alpha/APPENDIX.md", "digest-b", 20), + ])); + + let mut router = Router::new(DummyHandler); + router + .skill_router + .add_route(skill::SkillRoute::new(entry, dummy_skill_handler)); + + let request = + ClientRequest::ResourcesDirectoryReadRequest(ResourcesDirectoryReadRequest::new( + ResourcesDirectoryReadRequestParams::new("skill://alpha"), + )); + + let result = router + .handle_request(request, test_request_context()) + .await + .expect("router must answer resources/directory/read"); + + let ServerResult::ResourcesDirectoryReadResult(result) = result else { + panic!("expected ResourcesDirectoryReadResult, got {result:?}"); + }; + assert_eq!( + result.resources.len(), + 2, + "router must delegate the route's declared file list; got {:?}", + result.resources + ); + let uris: Vec<&str> = result.resources.iter().map(|r| r.uri.as_str()).collect(); + assert!( + uris.contains(&"skill://alpha/APPENDIX.md"), + "delegated list must include the route's files; got {uris:?}" + ); + } + + /// **Fix #4** — a directory-form URI (`skill://alpha`, no file component) + /// must resolve to its route, not just the file form (`skill://alpha/SKILL.md`). + /// + /// Goes RED before the fix (`uri.starts_with("skill://alpha/")` is false for + /// the bare directory form) and GREEN after. This is the routing layer + /// `resources/directory/read` depends on. + #[tokio::test] + async fn test_skill_router_matches_directory_form_uri() { + use crate::model::skills::SkillEntry; + + let entry = SkillEntry::new( + "skill://alpha/SKILL.md", + serde_json::json!({"name": "alpha", "description": "Alpha skill"}), + ); + let mut router = skill::SkillRouter::::new(); + router.add_route(skill::SkillRoute::new(entry, dummy_skill_handler)); + + assert!( + router.get_by_uri("skill://alpha/SKILL.md").is_some(), + "file-form URI must match" + ); + assert!( + router.get_by_uri("skill://alpha").is_some(), + "directory-form URI must match after fix" + ); + assert!( + router.get_by_uri("skill://nonexistent").is_none(), + "non-matching URI must not match" + ); + } + async fn recv_notification( rx: &mut tokio::sync::mpsc::Receiver>, ) -> ServerNotification { diff --git a/crates/rmcp/src/handler/server/router/skill.rs b/crates/rmcp/src/handler/server/router/skill.rs index edaa2ea38..f8080180d 100644 --- a/crates/rmcp/src/handler/server/router/skill.rs +++ b/crates/rmcp/src/handler/server/router/skill.rs @@ -163,13 +163,16 @@ where return None; } // For each registered route, extract its skill path (e.g. `acme/billing/refunds`) - // and check if the request URI starts with `skill:///`. - // This handles both exact SKILL.md matches and nested file URIs. + // and check if the request URI is either the directory form + // `skill://` (no trailing slash, no file component — this is + // what `resources/directory/read` sends) or starts with + // `skill:///` (an exact SKILL.md match or a nested file URI). let mut best: Option<(&Cow<'static, str>, &SkillRoute)> = None; for (key, route) in &self.map { if let Some(route_skill_path) = route.skill_path() { let prefix = format!("skill://{}/", route_skill_path); - if uri.starts_with(&prefix) { + let directory_form = format!("skill://{}", route_skill_path); + if uri == directory_form || uri.starts_with(&prefix) { match best { Some((best_key, _)) if best_key.len() >= key.len() => {} _ => best = Some((key, route)), diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index 8802b0a36..0c7c471f1 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -706,3 +706,60 @@ fn macro_server_resources_directory_read_is_dispatched() { ); } } + +// --------------------------------------------------------------------------- +// Regression tests for review findings (4 blocking items) +// --------------------------------------------------------------------------- + +/// **Fix #1**: Router's `resources/directory/read` must delegate to the +/// route's declared resources, not return a hardcoded empty list. +/// +/// This test drives the Router path (not the FS demo) by sending +/// `resources/directory/read` through a server that uses `SkillRouter`. +/// The FS demo has its own `resources_directory_read` impl, so it would +/// pass even with the Router bug. This test uses the macro server which +/// routes through the Router. +#[test] +fn router_directory_read_delegates_to_route() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + // Directory-form URI — this is what resources/directory/read sends. + build_request( + 2, + "resources/directory/read", + serde_json::json!({"uri": "skill://alpha"}), + ), + ]; + + let responses = run_macro_server_request(&requests); + let resp = responses + .iter() + .find(|r| r["id"] == 2) + .expect("should have resources/directory/read response"); + + assert!( + resp["error"].is_null(), + "Router must answer resources/directory/read, not error; got: {resp}" + ); + assert!( + resp["result"]["resources"].is_array(), + "Router must return a resources array; got: {resp}" + ); + // The macro server's alpha skill has no file list, so empty is correct. + // The bug was that the Router returned empty for ALL skills, even those + // with declared resources. This test proves the Router path is exercised. + let resources = resp["result"]["resources"].as_array().unwrap(); + assert_eq!( + resources.len(), + 0, + "alpha has no file list, so empty is correct; got: {resources:?}" + ); +} From 8a44b8fb3668fc2fbfa5639f61ea965fff5145ba Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Mon, 12 Oct 2026 15:05:58 -0700 Subject: [PATCH 17/26] fix(rmcp): fill cache hints on skill list results `ServerResult::fill_missing_cache_hints` covered the tools/prompts/resources result types but not the two SEP-2640 skill results, so a server that left their cache hints unset serialized `ttlMs`/`cacheScope` as absent. A client that trusts those fields then treats the response as non-cacheable. `cache_scope` is `Option` on the existing results but `Option` on `SkillsListResult` and `ResourcesDirectoryReadResult`, and a single `match` cannot borrow both shapes. Handle the two skill results in a helper, `fill_missing_cache_hints_for_string_scope`, that fills the same defaults (ttl 0, scope "private") for a complete result. The public field type is unchanged. `SkillsGetResult` is deliberately excluded: it carries no `ttl_ms` or `cache_scope` field (model/skills.rs:259-263). Evidence (fix reverted alone, re-run, then restored): - fill_missing_cache_hints_covers_skills_list_result RED: ttl_ms/cache_scope stayed None -> GREEN: pass - fill_missing_cache_hints_covers_resources_directory_read_result RED: ttl_ms/cache_scope stayed None -> GREEN: pass Scope: no public API change (kept `Option`). --- crates/rmcp/src/model.rs | 34 ++++++++++++ crates/rmcp/tests/test_skills_conformance.rs | 58 ++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 351994b7a..ce5c743bf 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -4861,6 +4861,10 @@ impl ServerResult { /// assert_eq!(result.cache_scope, Some(CacheScope::Private)); /// ``` pub fn fill_missing_cache_hints(&mut self) { + // `cache_scope` is `Option` on most result types but + // `Option` on the two SEP-2640 skill results (SkillsListResult + // and ResourcesDirectoryReadResult). A single `match` cannot mix both + // types, so handle the two groups separately. let (result_type, ttl_ms, cache_scope) = match self { ServerResult::ListToolsResult(r) => (&r.result_type, &mut r.ttl_ms, &mut r.cache_scope), ServerResult::ListPromptsResult(r) => { @@ -4875,6 +4879,22 @@ impl ServerResult { ServerResult::ReadResourceResult(r) => { (&r.result_type, &mut r.ttl_ms, &mut r.cache_scope) } + // SEP-2640 skill results with `Option` cache_scope. + // `SkillsGetResult` is absent on purpose: it has no cache fields. + ServerResult::SkillsListResult(r) => { + return fill_missing_cache_hints_for_string_scope( + &r.result_type, + &mut r.ttl_ms, + &mut r.cache_scope, + ); + } + ServerResult::ResourcesDirectoryReadResult(r) => { + return fill_missing_cache_hints_for_string_scope( + &r.result_type, + &mut r.ttl_ms, + &mut r.cache_scope, + ); + } _ => return, }; // Only complete results are cacheable; the spec treats an absent @@ -4887,6 +4907,20 @@ impl ServerResult { } } +/// Helper for `fill_missing_cache_hints` on SEP-2640 skill result types whose +/// `cache_scope` is `Option` rather than `Option`. +fn fill_missing_cache_hints_for_string_scope( + result_type: &Option, + ttl_ms: &mut Option, + cache_scope: &mut Option, +) { + if result_type.as_ref().is_some_and(|t| !t.is_complete()) { + return; + } + ttl_ms.get_or_insert(0); + cache_scope.get_or_insert("private".to_string()); +} + pub type ServerJsonRpcMessage = JsonRpcMessage; impl TryInto for ServerNotification { diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index 0c7c471f1..a7f54365c 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -763,3 +763,61 @@ fn router_directory_read_delegates_to_route() { "alpha has no file list, so empty is correct; got: {resources:?}" ); } + +/// **Fix #2**: `fill_missing_cache_hints` must cover `SkillsListResult` and +/// `ResourcesDirectoryReadResult`, not just the original 5 types. +/// +/// Before the fix, these two types fell to `_ => return` and never got +/// cache hints filled in. This test constructs them without hints, calls +/// `fill_missing_cache_hints`, and asserts the hints are now present. +#[test] +fn fill_missing_cache_hints_covers_skills_list_result() { + use rmcp::model::{ResultType, ServerResult, skills::SkillsListResult}; + + let mut result = SkillsListResult::new(vec![]); + result.result_type = Some(ResultType::COMPLETE); + // Simulate a server that didn't set cache hints. + result.ttl_ms = None; + result.cache_scope = None; + + let mut server_result = ServerResult::SkillsListResult(result); + server_result.fill_missing_cache_hints(); + + let ServerResult::SkillsListResult(r) = &server_result else { + panic!("expected SkillsListResult"); + }; + assert!( + r.ttl_ms.is_some(), + "fill_missing_cache_hints must set ttl_ms on SkillsListResult; got: {r:?}" + ); + assert!( + r.cache_scope.is_some(), + "fill_missing_cache_hints must set cache_scope on SkillsListResult; got: {r:?}" + ); +} + +/// **Fix #2**: Same for `ResourcesDirectoryReadResult`. +#[test] +fn fill_missing_cache_hints_covers_resources_directory_read_result() { + use rmcp::model::{ResultType, ServerResult, skills::ResourcesDirectoryReadResult}; + + let mut result = ResourcesDirectoryReadResult::new(vec![]); + result.result_type = Some(ResultType::COMPLETE); + result.ttl_ms = None; + result.cache_scope = None; + + let mut server_result = ServerResult::ResourcesDirectoryReadResult(result); + server_result.fill_missing_cache_hints(); + + let ServerResult::ResourcesDirectoryReadResult(r) = &server_result else { + panic!("expected ResourcesDirectoryReadResult"); + }; + assert!( + r.ttl_ms.is_some(), + "fill_missing_cache_hints must set ttl_ms on ResourcesDirectoryReadResult; got: {r:?}" + ); + assert!( + r.cache_scope.is_some(), + "fill_missing_cache_hints must set cache_scope on ResourcesDirectoryReadResult; got: {r:?}" + ); +} From 074fb9522c22b0fc7769126a39ed0cefbb681836 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Mon, 12 Oct 2026 15:06:23 -0700 Subject: [PATCH 18/26] fix(rmcp): advertise skills extension in the fs example server The hand-written `skill_fs_server` example implements `skills_list`, `skills_get`, and `read_resource` but never overrode `get_info`, so its `initialize` response advertised no capabilities. A conformant client reads that response to decide which methods to call, so it never issued `skills/list` and the skill surface was invisible. Override `get_info` to declare the `resources` capability and the `io.modelcontextprotocol.skills` extension, matching what the macro-generated servers advertise. Evidence (fix reverted alone, re-run, then restored): - fs_demo_advertises_skills_extension_and_resources RED: "FS demo must advertise resources capability; got: {}" -> GREEN: pass Scope: example server only, no library change. --- crates/rmcp/examples/skill_fs_server.rs | 22 ++++++++- crates/rmcp/tests/test_skills_conformance.rs | 51 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/examples/skill_fs_server.rs b/crates/rmcp/examples/skill_fs_server.rs index 78ef2a6f5..8673c834b 100644 --- a/crates/rmcp/examples/skill_fs_server.rs +++ b/crates/rmcp/examples/skill_fs_server.rs @@ -18,7 +18,7 @@ use std::{ use rmcp::{ ServerHandler, ServiceExt, model::{ - Resource, + Implementation, InitializeResult, Resource, ServerCapabilities, skills::{ self, SkillEntry, SkillResource, SkillResources, SkillsGetRequestParams, SkillsGetResult, SkillsListResult, @@ -238,6 +238,26 @@ impl FileSystemSkillServer { } impl ServerHandler for FileSystemSkillServer { + /// Per SEP-2640, a server that supports Skills MUST declare both the + /// `resources` capability and the `io.modelcontextprotocol/skills` + /// extension. The hand-written server gets no generated `get_info`, so it + /// must advertise them here or a conformant client never calls `skills/list`. + fn get_info(&self) -> InitializeResult { + InitializeResult::new( + ServerCapabilities::builder() + .enable_resources() + .enable_skills_extension() + .build(), + ) + .with_server_info(Implementation::new( + "skill-fs-server", + env!("CARGO_PKG_VERSION"), + )) + .with_instructions( + "File-system-backed MCP server exposing SEP-2640 skills discovered from a root directory.", + ) + } + async fn skills_list( &self, _request: Option, diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index a7f54365c..addd9e9ed 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -821,3 +821,54 @@ fn fill_missing_cache_hints_covers_resources_directory_read_result() { "fill_missing_cache_hints must set cache_scope on ResourcesDirectoryReadResult; got: {r:?}" ); } + +/// **Fix #3**: The FS demo server must advertise both the `resources` +/// capability and the `io.modelcontextprotocol/skills` extension in its +/// `initialize` response. Without this, a conformant client never calls +/// `skills/list`. +#[test] +fn fs_demo_advertises_skills_extension_and_resources() { + let requests = vec![build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + )]; + + let responses = run_server_request(&requests); + let init_resp = responses + .iter() + .find(|r| r["id"] == 1) + .expect("should have initialize response"); + + let capabilities = &init_resp["result"]["capabilities"]; + + assert!( + !capabilities["resources"].is_null(), + "FS demo must advertise resources capability; got: {capabilities}" + ); + + let extensions = &capabilities["extensions"]; + assert!( + !extensions.is_null(), + "FS demo must advertise extensions map; got: {capabilities}" + ); + assert!( + !extensions["io.modelcontextprotocol/skills"].is_null(), + "FS demo must advertise io.modelcontextprotocol/skills extension; got: {extensions}" + ); +} + +// **Fix #4** — directory-form URIs (`skill://alpha`) must be routable, +// not just file-form (`skill://alpha/SKILL.md`). +// +// Covered by the router-level unit test +// `router::tests::test_skill_router_matches_directory_form_uri`, which needs a +// real `SkillRoute` (and therefore a `for<'a>` handler) that this +// process-spawning integration file cannot construct. +// +// **Fix #1** — `resources/directory/read` delegation is covered by +// `router::tests::test_router_directory_read_delegates_to_file_list_route`. From 0c8d1a91c4a8acc9a256d9fcdc52a985017beaf6 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Tue, 13 Oct 2026 07:42:47 -0700 Subject: [PATCH 19/26] fix(rmcp): remove crate-wide allow(dead_code) and address SEP-2640 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-breaking cleanups from the #1286 review. No public API change. - lib.rs: drop the crate-wide `#![allow(dead_code)]` (added in 8c7465c). The precedent already exists in this PR: the identical blanket was removed from crates/rmcp-macros/src/lib.rs and CI stayed green. Verified clean under `cargo clippy --all-targets --all-features -- -D warnings`, the reduced-feature matrix `cargo hack clippy -p rmcp --lib --each-feature --exclude-features local -- -D warnings` (28/28), and an explicit `--no-default-features --features macros` pass. - rmcp-macros/src/skill.rs: drop the `description` and `local` attributes from `#[skill]`. Neither can be wired through: `CallSkillHandler::call` fixes the future to `+ Send` (handler/server/skill.rs), so a non-`Send` future is unrepresentable; and `SkillEntry` has no description field (the spec requires frontmatter be verbatim). Also removes the two spurious item-level `#[allow(dead_code)]` and the now-unused `Span`/`LitStr`/`extract_doc_line` imports. - handler/server/router.rs: `Router::get_info` now advertises the skills extension when skill routes exist, so a Router-based server actually tells conformant clients to call `skills/list`. The inner service's capabilities are preserved — only the extension entry is added. - model/capabilities.rs: extract `insert_skills_extension_into`, shared by the new in-place path and the existing `enable_skills_extension()` builder, so the two cannot drift. - service/client.rs: `read_skill_uri` returns `ServiceError::UnexpectedResponse` on an `InputRequired` response instead of `unreachable!`; a non-conforming server must not abort the client. Doc comment corrected to match the code. - .gitignore: drop the unrelated `.serena/` entry. Does NOT change `cache_scope` (still `Option`; a typed `CacheScope` would be a public-API break) and does NOT touch the upstream `allow(dead_code)` in transport/common/*, which are upstream's accepted pattern. --- .gitignore | 1 - crates/rmcp-macros/src/skill.rs | 29 ++------------------ crates/rmcp/src/handler/server/router.rs | 7 +++++ crates/rmcp/src/lib.rs | 1 - crates/rmcp/src/model/capabilities.rs | 34 ++++++++++++++++++++---- crates/rmcp/src/service/client.rs | 18 ++++++++----- 6 files changed, 49 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index bf958b224..2288665b1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ # will have compiled files and executables debug/ target/ -.serena/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html diff --git a/crates/rmcp-macros/src/skill.rs b/crates/rmcp-macros/src/skill.rs index c694fb362..b88bb198d 100644 --- a/crates/rmcp-macros/src/skill.rs +++ b/crates/rmcp-macros/src/skill.rs @@ -7,13 +7,10 @@ //! `Pin>` so the gradebook can call it synchronously. use darling::{FromMeta, ast::NestedMeta}; -use proc_macro2::{Span, TokenStream}; +use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Expr, Ident, ImplItemFn, LitStr, parse_quote}; +use syn::{Expr, Ident, ImplItemFn, parse_quote}; -use crate::common::extract_doc_line; - -#[allow(dead_code)] #[derive(FromMeta, Default, Debug)] #[darling(default)] pub struct SkillAttribute { @@ -21,19 +18,13 @@ pub struct SkillAttribute { 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, } @@ -42,14 +33,8 @@ impl ResolvedSkillAttribute { 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 { @@ -124,19 +109,9 @@ pub fn skill(attr: TokenStream, input: TokenStream) -> syn::Result ) })?; - 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.clone())?; diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index 6a54e99d0..584a79ba9 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -228,6 +228,13 @@ where .tools .get_or_insert_with(Default::default) .list_changed = Some(true); + // A `Router` that owns skill routes must advertise the SEP-2640 skills + // extension; otherwise a conformant client never calls `skills/list`. + // The inner service's own capabilities are preserved — we only add the + // extension entry. + if !self.skill_router.map.is_empty() { + info.capabilities.insert_skills_extension(); + } info } diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index b1aff313f..3be6616ed 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -1,6 +1,5 @@ #![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/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 5154fc0a1..0e4013887 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -253,6 +253,34 @@ impl ServerCapabilities { .as_ref() .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) } + + /// Advertise the `io.modelcontextprotocol/skills` extension (SEP-2640) in + /// the `extensions` capability map with `directoryRead: true`. + /// + /// In-place equivalent of the `enable_skills_extension()` builder method, + /// for code that already holds a `ServerCapabilities` value (e.g. a + /// `Router` forwarding its inner service's capabilities). + /// + /// Gated to `server` (not `macros`): its only caller is the server-gated + /// `Router`, so under a `macros`-only build it would be dead code. + #[cfg(feature = "server")] + pub(crate) fn insert_skills_extension(&mut self) { + insert_skills_extension_into(&mut self.extensions); + } +} + +/// Write the `io.modelcontextprotocol/skills` extension entry (SEP-2640) into +/// an `extensions` capability map with `directoryRead: true`. +/// +/// Shared by [`ServerCapabilities::insert_skills_extension`] (in-place) and the +/// `enable_skills_extension()` builder method so the two cannot drift. +#[cfg(any(feature = "server", feature = "macros"))] +pub(crate) fn insert_skills_extension_into(extensions: &mut Option) { + let mut dir_read = JsonObject::new(); + dir_read.insert("directoryRead".to_string(), serde_json::json!(true)); + extensions + .get_or_insert_with(ExtensionCapabilities::new) + .insert(super::SKILLS_EXTENSION_ID.to_string(), dir_read); } #[cfg(any(feature = "server", feature = "macros"))] @@ -442,11 +470,7 @@ impl ServerCapabilitiesBuilder { /// Advertise the `io.modelcontextprotocol/skills` extension (SEP-2640) in the /// `extensions` capability map with `directoryRead: true`. pub fn enable_skills_extension(mut self) -> Self { - let mut dir_read = JsonObject::new(); - dir_read.insert("directoryRead".to_string(), serde_json::json!(true)); - self.extensions - .get_or_insert_with(ExtensionCapabilities::new) - .insert(super::SKILLS_EXTENSION_ID.to_string(), dir_read); + insert_skills_extension_into(&mut self.extensions); self } } diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 293d56d78..f3bac0896 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -1979,6 +1979,9 @@ impl Peer { /// /// Delegates to [`Peer::read_resource`] with a resource reference /// built from the URI. + /// + /// A server that answers with an `InputRequired` variant (undefined for + /// skill files) yields [`ServiceError::UnexpectedResponse`]. pub async fn read_skill_uri( peer: &Peer, uri: impl Into, @@ -1986,14 +1989,15 @@ impl Peer { let params = ReadResourceRequestParams::new(uri); peer.read_resource_once(params) .await - .map(|response| match response { - ReadResourceResponse::Complete(result) => result, + .and_then(|response| match response { + ReadResourceResponse::Complete(result) => Ok(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") + // The skills extension does not define input_required for + // skill files, so an `InputRequired` response here means the + // server returned a variant this wrapper does not handle. + // Surface it as an error rather than panicking — a + // non-conforming server must not abort the client. + Err(ServiceError::UnexpectedResponse) } }) } From caa4eab7aaaa33c25012827daa40e232ea9387a5 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Tue, 13 Oct 2026 11:18:51 -0700 Subject: [PATCH 20/26] fix(rmcp): resolve ten SEP-2640 spec conformance defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten spec violations fixed, each proven RED→GREEN against the SEP-2640 spec. D1/D8: Router::get_info now advertises the resources capability when skill routes exist (spec line 389). RED: resources: None while the extension was advertised. D2: Directory read returns direct children only, non-recursive. Subdirectories collapse to one child carrying mimeType "inode/directory". SKILL.md is a direct child of the skill root and MUST be listed (spec: "every direct child"). D3: A skill:// URI that exists but is not a directory returns -32602 (spec line 453). Both macro and Router paths. D4: Scheme guard — unknown skill:// URIs return -32602; non-skill URIs delegate to the inner service. D5: validate_skill_name is now ENFORCED (was dead code). Macro filters mismatched entries, Router filters too. D6: parse_skill_uri documentation narrowed to SKILL.md-form route URIs only. No signature change; the last-/ split is documented as a heuristic, not a general-purpose parser. D7: Removed unreachable if file.is_empty() branch from skill_path_from_uri (skills.rs). D9: SkillEntry::new() now defaults resources to SkillResources::Dynamic. Spec line 259: resources is REQUIRED; a host MUST NOT load an entry with null. D10: Router skills/list sets ttl_ms/cache_scope only when protocol >= 2026-07-28 (was hardcoded None). Does NOT touch: cache_scope type (stays Option, no public API break), Router::get_info capability-writing mechanics, or any pushed commits. Gates: fmt exit 0, clippy clean, clippy --each-feature 28/28 clean, lib 236/0, macros 10/0, conformance 19/0. --- crates/rmcp-macros/src/skill_handler.rs | 94 +++- crates/rmcp/examples/skill_fs_server.rs | 6 +- crates/rmcp/src/handler/server/router.rs | 517 +++++++++++++++++- crates/rmcp/src/model/skills.rs | 24 +- .../server_json_rpc_message_schema.json | 2 +- crates/rmcp/tests/test_skills_conformance.rs | 90 ++- 6 files changed, 678 insertions(+), 55 deletions(-) diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index ea04103a2..9f0b30570 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -100,7 +100,16 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result= rmcp::model::ProtocolVersion::V_2026_07_28 }); - let mut result = rmcp::model::skills::SkillsListResult::new(#router.list_all()); + // Spec: the final `` segment of `uri` MUST equal + // `frontmatter.name`. Entries that violate it are invalid, so + // they are not served. + let skills = #router.list_all().into_iter() + .filter(|entry| { + rmcp::model::skills::validate_skill_name(&entry.uri, &entry.frontmatter) + .is_ok() + }) + .collect(); + let mut result = rmcp::model::skills::SkillsListResult::new(skills); result.result_type = Some(rmcp::model::ResultType::COMPLETE); result.ttl_ms = supports_cache_hints.then_some(0); result.cache_scope = supports_cache_hints.then_some("public".to_string()); @@ -129,6 +138,10 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result` segment of `uri` MUST equal + // `frontmatter.name`. + rmcp::model::skills::validate_skill_name(&skill_entry.uri, &skill_entry.frontmatter) + .map_err(|e| rmcp::ErrorData::invalid_params(e, None))?; Ok(rmcp::model::skills::SkillsGetResult::new(skill_entry)) } })?; @@ -145,22 +158,75 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result= rmcp::model::ProtocolVersion::V_2026_07_28 }); - // Enumerate files for multi-file skills + // The method applies only to directory resources. The skill root + // (`skill://`) is a directory; a file URI such as + // `skill:///SKILL.md` is not, and MUST return -32602. let router = #router; - let resources: 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::Resource::new(file.uri.clone(), name) - }).collect() + let resources: Vec = match router.get_by_uri(&request.uri) { + Some(route) => { + let directory_form = format!( + "skill://{}", + route.skill_path().unwrap_or_default() + ); + if request.uri != directory_form { + return Err(rmcp::ErrorData::invalid_params( + format!("not a directory: {}", request.uri), + None, + )); } - Some(rmcp::model::skills::SkillResources::Dynamic) => vec![], - Some(_) => vec![], - None => vec![], + match &route.attr.resources { + Some(rmcp::model::skills::SkillResources::FileList(files)) => { + // Spec, Directory Listing: "The result contains + // every direct child of the directory." SKILL.md + // is a file directly inside the skill root, so it + // IS a direct child and MUST be listed. + let root = route.attr.uri + .strip_suffix("/SKILL.md") + .or_else(|| route.attr.uri.strip_suffix("SKILL.md")) + .unwrap_or(&route.attr.uri); + let prefix = format!("{}/", root); + // Direct children only: a file maps to itself, a + // nested path maps to its first segment as a + // directory resource. The listing is not recursive. + let mut children: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for file in files { + let Some(rest) = file.uri.strip_prefix(&prefix) else { + continue; + }; + if rest.is_empty() { + continue; + } + match rest.split_once('/') { + Some((head, _)) => { + children.insert(format!("{}/{}", root, head), true); + } + None => { + children.entry(file.uri.clone()).or_insert(false); + } + } + } + children.into_iter().map(|(uri, is_dir)| { + let name = uri.rsplit('/').next().unwrap_or("").to_string(); + let resource = rmcp::model::Resource::new(uri, name); + if is_dir { + resource.with_mime_type("inode/directory") + } else { + resource + } + }).collect() + } + _ => vec![], + } + } + None => { + // Spec line 453: a URI that does not identify a skill the + // server serves MUST return -32602, not an empty list. + return Err(rmcp::ErrorData::invalid_params( + format!("unknown skill URI: {}", request.uri), + None, + )); } - } else { - vec![] }; let mut result = rmcp::model::skills::ResourcesDirectoryReadResult::new(resources); result.result_type = Some(rmcp::model::ResultType::COMPLETE); diff --git a/crates/rmcp/examples/skill_fs_server.rs b/crates/rmcp/examples/skill_fs_server.rs index 8673c834b..8b0d95605 100644 --- a/crates/rmcp/examples/skill_fs_server.rs +++ b/crates/rmcp/examples/skill_fs_server.rs @@ -216,9 +216,9 @@ impl FileSystemSkillServer { for entry in fs::read_dir(&dir_to_read).ok()?.flatten() { let path = entry.path(); let name = path.file_name()?.to_str()?; - if name == SKILL_FILE { - continue; - } + // Spec, Directory Listing: "The result contains every direct child of + // the directory." SKILL.md is a file directly inside the skill root, + // so it IS a direct child and MUST be listed. Do NOT skip it. // Canonicalize each entry and verify it stays within root let canonical_entry = path.canonicalize().ok()?; if !canonical_entry.starts_with(&canonical_root) { diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index 584a79ba9..2f760c4ce 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -159,14 +159,28 @@ where })) } ClientRequest::SkillsListRequest(_) => { - let skills = self.skill_router.list_all(); + // Spec: the final `` segment of `uri` MUST equal + // `frontmatter.name`. Entries that violate it are invalid, so + // they are not served. + let skills: Vec<_> = self + .skill_router + .list_all() + .into_iter() + .filter(|entry| { + crate::model::skills::validate_skill_name(&entry.uri, &entry.frontmatter) + .is_ok() + }) + .collect(); + let supports_cache_hints = context + .protocol_version() + .is_some_and(|version| version >= crate::model::ProtocolVersion::V_2026_07_28); Ok(ServerResult::SkillsListResult( crate::model::skills::SkillsListResult { result_type: Some(crate::model::ResultType::COMPLETE), - skills: skills.into_iter().collect(), + skills, next_cursor: None, - ttl_ms: None, - cache_scope: None, + ttl_ms: supports_cache_hints.then_some(0), + cache_scope: supports_cache_hints.then_some("public".to_string()), }, )) } @@ -193,23 +207,86 @@ where // resources. A route with no static file list yields an empty // array, which is the correct answer for a file-less skill. if let Some(route) = self.skill_router.get_by_uri(&request.params.uri) { + // Spec: the method applies only to directory resources. The + // skill root (`skill://`) is a directory; a file + // URI such as `skill:///SKILL.md` is not, and + // MUST return -32602. + let directory_form = + format!("skill://{}", route.skill_path().unwrap_or_default()); + if request.params.uri != directory_form { + return Err(crate::ErrorData::invalid_params( + format!("not a directory: {}", request.params.uri), + None, + )); + } let resources: Vec = match &route.attr.resources { - Some(crate::model::skills::SkillResources::FileList(files)) => files - .iter() - .map(|f| { - let name = f.uri.rsplit('/').next().unwrap_or("").to_string(); - crate::model::Resource::new(f.uri.clone(), name) - }) - .collect(), + Some(crate::model::skills::SkillResources::FileList(files)) => { + // Spec, Directory Listing: "The result contains every + // direct child of the directory." SKILL.md is a file + // directly inside the skill root, so it IS a direct + // child and MUST be listed (it is also in the skill's + // own `resources` manifest). + let root = route + .attr + .uri + .strip_suffix("/SKILL.md") + .or_else(|| route.attr.uri.strip_suffix("SKILL.md")) + .unwrap_or(&route.attr.uri); + let prefix = format!("{}/", root); + // Direct children only: a file maps to itself, a + // nested path maps to its first segment as a + // directory resource. The listing is not recursive. + let mut children: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for file in files { + let Some(rest) = file.uri.strip_prefix(&prefix) else { + continue; + }; + if rest.is_empty() { + continue; + } + match rest.split_once('/') { + Some((head, _)) => { + children.insert(format!("{}/{}", root, head), true); + } + None => { + children.entry(file.uri.clone()).or_insert(false); + } + } + } + children + .into_iter() + .map(|(uri, is_dir)| { + let name = uri.rsplit('/').next().unwrap_or("").to_string(); + let resource = crate::model::Resource::new(uri, name); + if is_dir { + resource.with_mime_type("inode/directory") + } else { + resource + } + }) + .collect() + } _ => Vec::new(), }; let mut result = crate::model::skills::ResourcesDirectoryReadResult::new(resources); result.result_type = Some(crate::model::ResultType::COMPLETE); Ok(ServerResult::ResourcesDirectoryReadResult(result)) + } else if request.params.uri.starts_with("skill://") { + // A `skill://` URI the router does not serve is a request for + // a skill this server does not have. Spec line 453: a + // non-existent URI MUST return `-32602`, the same code + // `resources/read` uses for unknown resources. Delegating to + // the inner service would answer `-32601` (method not found), + // which misreports a missing skill as an unsupported method. + Err(crate::ErrorData::invalid_params( + format!("unknown skill URI: {}", request.params.uri), + None, + )) } else { - // No router-declared skill for this URI — let the inner - // service answer (it may have its own directory logic). + // Not a `skill://` URI — the router has no opinion. Let the + // inner service answer (it may have its own directory logic). self.service .handle_request( ClientRequest::ResourcesDirectoryReadRequest(request), @@ -234,6 +311,12 @@ where // extension entry. if !self.skill_router.map.is_empty() { info.capabilities.insert_skills_extension(); + // Spec line 389: "A server declaring this extension MUST also + // declare the `resources` capability." Without this, a conformant + // client may reject the server as non-compliant. + info.capabilities + .resources + .get_or_insert_with(Default::default); } info } @@ -332,10 +415,14 @@ mod tests { let ServerResult::ResourcesDirectoryReadResult(result) = result else { panic!("expected ResourcesDirectoryReadResult, got {result:?}"); }; + // The route declares SKILL.md (its own root) and APPENDIX.md. A directory + // listing carries every direct child, and SKILL.md is a file directly + // inside the skill root, so it IS a direct child and is listed. assert_eq!( result.resources.len(), 2, - "router must delegate the route's declared file list; got {:?}", + "router must delegate the route's declared file list, including \ + SKILL.md as a direct child; got {:?}", result.resources ); let uris: Vec<&str> = result.resources.iter().map(|r| r.uri.as_str()).collect(); @@ -343,6 +430,10 @@ mod tests { uris.contains(&"skill://alpha/APPENDIX.md"), "delegated list must include the route's files; got {uris:?}" ); + assert!( + uris.contains(&"skill://alpha/SKILL.md"), + "SKILL.md is a direct child of the skill root and MUST be listed; got {uris:?}" + ); } /// **Fix #4** — a directory-form URI (`skill://alpha`, no file component) @@ -376,6 +467,404 @@ mod tests { ); } + /// **D1/D8** — a `Router` that owns skill routes must advertise BOTH the + /// SEP-2640 skills extension AND the `resources` capability. + /// + /// Spec line 389: *"A server declaring this extension MUST also declare the + /// `resources` capability."* Goes RED before the fix (only the extension is + /// written), GREEN after. The second half is a regression guard: a router + /// with no skill routes must stay byte-identical to the inner service. + #[tokio::test] + async fn test_router_get_info_advertises_skills_extension_and_resources() { + use crate::model::skills::SkillEntry; + // No routes -> must not add the resources capability. + use crate::service::Service; + let bare = Router::new(DummyHandler); + let bare_info = bare.get_info(); + assert!( + bare_info.capabilities.resources.is_none(), + "a router with no skill routes must not add the resources capability; got {:?}", + bare_info.capabilities + ); + + // With a route -> extension entry AND the resources capability. + let entry = SkillEntry::new( + "skill://alpha/SKILL.md", + serde_json::json!({"name": "alpha", "description": "Alpha skill"}), + ); + let mut router = Router::new(DummyHandler); + router + .skill_router + .add_route(skill::SkillRoute::new(entry, dummy_skill_handler)); + + let info = router.get_info(); + assert!( + info.capabilities.resources.is_some(), + "spec line 389: declaring the skills extension MUST also declare `resources`; got {:?}", + info.capabilities + ); + let ext = info + .capabilities + .extensions + .as_ref() + .and_then(|e| e.get(crate::model::SKILLS_EXTENSION_ID)) + .expect("skills extension must be advertised when skill routes exist"); + assert_eq!(ext["directoryRead"], serde_json::json!(true)); + } + + /// **D2** — a directory read returns every *direct* child: files as ordinary + /// resources, subdirectories as directory resources (`mimeType: + /// "inode/directory"`). The listing is not recursive. + /// + /// Spec: "The result contains every direct child of the directory: files + /// with their ordinary resource metadata, subdirectories listed as + /// directory resources (`mimeType: "inode/directory"`). The listing is not + /// recursive; clients descend by calling the method again on a child + /// directory." + #[tokio::test] + async fn test_router_directory_read_lists_direct_children_with_directory_mime() { + use crate::{ + model::{ + ClientRequest, + skills::{ + ResourcesDirectoryReadRequest, ResourcesDirectoryReadRequestParams, SkillEntry, + SkillResource, SkillResources, + }, + }, + service::Service, + }; + + let mut entry = SkillEntry::new( + "skill://pdf-processing/SKILL.md", + serde_json::json!({"name": "pdf-processing", "description": "PDF skill"}), + ); + entry.resources = Some(SkillResources::FileList(vec![ + // The skill's own SKILL.md — a direct child of the skill root. + SkillResource::new("skill://pdf-processing/SKILL.md", "d0", 1), + // Direct child file. + SkillResource::new("skill://pdf-processing/README.md", "d1", 2), + // Two files under templates/ — must collapse to ONE directory child. + SkillResource::new("skill://pdf-processing/templates/invoice.md", "d2", 3), + SkillResource::new("skill://pdf-processing/templates/order.md", "d3", 4), + ])); + + let mut router = Router::new(DummyHandler); + router + .skill_router + .add_route(skill::SkillRoute::new(entry, dummy_skill_handler)); + + let request = + ClientRequest::ResourcesDirectoryReadRequest(ResourcesDirectoryReadRequest::new( + ResourcesDirectoryReadRequestParams::new("skill://pdf-processing"), + )); + + let result = router + .handle_request(request, test_request_context()) + .await + .expect("router must answer resources/directory/read"); + let ServerResult::ResourcesDirectoryReadResult(result) = result else { + panic!("expected ResourcesDirectoryReadResult, got {result:?}"); + }; + + let uris: Vec<&str> = result.resources.iter().map(|r| r.uri.as_str()).collect(); + assert_eq!( + uris, + vec![ + "skill://pdf-processing/README.md", + "skill://pdf-processing/SKILL.md", + "skill://pdf-processing/templates" + ], + "must list every direct child (SKILL.md included, templates/ collapsed); got {uris:?}" + ); + + // The subdirectory child must be marked as a directory resource. + let dir_child = result + .resources + .iter() + .find(|r| r.uri == "skill://pdf-processing/templates") + .expect("templates must appear as a child"); + assert_eq!( + dir_child.mime_type.as_deref(), + Some("inode/directory"), + "subdirectory children must carry mimeType inode/directory" + ); + + // The plain file child must NOT be marked as a directory. + let file_child = result + .resources + .iter() + .find(|r| r.uri == "skill://pdf-processing/README.md") + .expect("README.md must appear as a child"); + assert_ne!( + file_child.mime_type.as_deref(), + Some("inode/directory"), + "plain files must not be marked inode/directory" + ); + } + + /// **D5** — the `frontmatter.name` ↔ URI-final-segment invariant is enforced, + /// not merely declared. + /// + /// Spec: "The final `` segment of the entry's `uri` MUST equal + /// `frontmatter.name`." `validate_skill_name` existed but had no non-test + /// caller, so an entry that violated the invariant was served anyway. A + /// violating entry is invalid, so it must not appear in `skills/list`. + #[tokio::test] + async fn test_router_skills_list_filters_name_uri_mismatch() { + use crate::{ + model::{ + ClientRequest, PaginatedRequestParams, RequestOptionalParam, skills::SkillEntry, + }, + service::Service, + }; + + // Conforming: uri final segment "alpha" == frontmatter.name "alpha". + let good = SkillEntry::new( + "skill://alpha/SKILL.md", + serde_json::json!({"name": "alpha", "description": "Alpha skill"}), + ); + // Non-conforming: uri final segment "beta" != frontmatter.name "wrong". + let bad = SkillEntry::new( + "skill://beta/SKILL.md", + serde_json::json!({"name": "wrong", "description": "Mismatched skill"}), + ); + + let mut router = Router::new(DummyHandler); + router + .skill_router + .add_route(skill::SkillRoute::new(good, dummy_skill_handler)); + router + .skill_router + .add_route(skill::SkillRoute::new(bad, dummy_skill_handler)); + + // Both routes are registered; the invariant is enforced at serve time. + assert_eq!( + router.skill_router.list_all().len(), + 2, + "both routes must be registered for this test to be meaningful" + ); + + let request = ClientRequest::SkillsListRequest(RequestOptionalParam::with_param( + PaginatedRequestParams::default(), + )); + let result = router + .handle_request(request, test_request_context()) + .await + .expect("skills/list must succeed"); + let ServerResult::SkillsListResult(result) = result else { + panic!("expected SkillsListResult, got {result:?}"); + }; + + let uris: Vec<&str> = result.skills.iter().map(|s| s.uri.as_str()).collect(); + assert_eq!( + uris, + vec!["skill://alpha/SKILL.md"], + "the entry whose frontmatter.name disagrees with its URI must not be served; got {uris:?}" + ); + } + + /// **D4** — Router `resources/directory/read` must return `-32602` for + /// unknown `skill://` URIs, not delegate to the inner service. + /// + /// Spec line 453: "A non-existent or non-directory URI MUST return `-32602`." + /// RED before fix (delegates to inner service → -32601), GREEN after. + #[tokio::test] + async fn test_router_directory_read_returns_32602_for_unknown_skill_uri() { + use crate::{ + model::{ + ClientRequest, + skills::{ + ResourcesDirectoryReadRequest, ResourcesDirectoryReadRequestParams, SkillEntry, + }, + }, + service::Service, + }; + + let entry = SkillEntry::new( + "skill://alpha/SKILL.md", + serde_json::json!({"name": "alpha", "description": "Alpha skill"}), + ); + let mut router = Router::new(DummyHandler); + router + .skill_router + .add_route(skill::SkillRoute::new(entry, dummy_skill_handler)); + + let request = + ClientRequest::ResourcesDirectoryReadRequest(ResourcesDirectoryReadRequest::new( + ResourcesDirectoryReadRequestParams::new("skill://nonexistent"), + )); + + let result = router.handle_request(request, test_request_context()).await; + match result { + Err(e) => assert_eq!( + e.code, + crate::model::ErrorCode(-32602), + "must return -32602 for unknown skill:// URI, got {:?}", + e.code + ), + Ok(r) => panic!("expected error, got {:?}", r), + } + } + + /// **D4 scheme guard** — non-`skill://` URIs must delegate to inner service, + /// not get -32602. A non-skill URI is not the router's responsibility. + #[tokio::test] + async fn test_router_directory_read_delegates_non_skill_uri() { + use crate::{ + model::{ + ClientRequest, + skills::{ + ResourcesDirectoryReadRequest, ResourcesDirectoryReadRequestParams, SkillEntry, + }, + }, + service::Service, + }; + + let entry = SkillEntry::new( + "skill://alpha/SKILL.md", + serde_json::json!({"name": "alpha", "description": "Alpha skill"}), + ); + let mut router = Router::new(DummyHandler); + router + .skill_router + .add_route(skill::SkillRoute::new(entry, dummy_skill_handler)); + + let request = + ClientRequest::ResourcesDirectoryReadRequest(ResourcesDirectoryReadRequest::new( + ResourcesDirectoryReadRequestParams::new("file:///etc/passwd"), + )); + + let result = router.handle_request(request, test_request_context()).await; + match result { + Err(e) => assert_eq!( + e.code, + crate::model::ErrorCode(-32601), + "non-skill URI must delegate to inner service (got -32601), got {:?}", + e.code + ), + Ok(r) => panic!("expected delegation error, got {:?}", r), + } + } + + /// **D10** — Router `skills/list` must set cache hints when the protocol + /// version is 2026-07-28+, matching the macro path's behavior. + /// + /// Spec line 229: "In protocol versions 2026-07-28 and later, the result + /// also carries the base protocol's list-caching attributes." + /// Repo contract (skills.rs:63-64): "REQUIRED on skills/list results in + /// protocol version 2026-07-28+." + /// + /// RED before fix (ttl_ms/cache_scope are None). GREEN after. + #[tokio::test] + async fn test_router_skills_list_sets_cache_hints_on_new_protocol() { + use crate::{model::skills::SkillEntry, service::Service}; + + let entry = SkillEntry::new( + "skill://alpha/SKILL.md", + serde_json::json!({"name": "alpha", "description": "Alpha skill"}), + ); + let mut router = Router::new(DummyHandler); + router + .skill_router + .add_route(skill::SkillRoute::new(entry, dummy_skill_handler)); + + // Build a RequestContext with protocol version 2026-07-28. + let (peer, _rx) = crate::service::Peer::new( + std::sync::Arc::new(crate::service::AtomicU32RequestIdProvider::default()), + None, + ); + let mut meta = crate::model::RequestMetaObject::default(); + meta.set_protocol_version(crate::model::ProtocolVersion::V_2026_07_28); + let context = crate::service::RequestContext { + ct: tokio_util::sync::CancellationToken::new(), + id: crate::model::NumberOrString::String("test".to_string().into()), + meta, + extensions: crate::model::Extensions::default(), + peer, + }; + + let request = crate::model::ClientRequest::SkillsListRequest( + crate::model::RequestOptionalParam::with_param( + crate::model::PaginatedRequestParams::default(), + ), + ); + + let result = router + .handle_request(request, context) + .await + .expect("must succeed"); + match result { + crate::model::ServerResult::SkillsListResult(r) => { + assert!( + r.ttl_ms.is_some(), + "ttl_ms must be set on skills/list in protocol 2026-07-28+; got {:?}", + r.ttl_ms + ); + assert!( + r.cache_scope.is_some(), + "cache_scope must be set on skills/list in protocol 2026-07-28+; got {:?}", + r.cache_scope + ); + } + other => panic!("expected SkillsListResult, got {:?}", other), + } + } + + /// **D10 negative** — Router `skills/list` must NOT set cache hints when the + /// protocol version is older than 2026-07-28. + #[tokio::test] + async fn test_router_skills_list_no_cache_hints_on_old_protocol() { + use crate::{model::skills::SkillEntry, service::Service}; + + let entry = SkillEntry::new( + "skill://alpha/SKILL.md", + serde_json::json!({"name": "alpha", "description": "Alpha skill"}), + ); + let mut router = Router::new(DummyHandler); + router + .skill_router + .add_route(skill::SkillRoute::new(entry, dummy_skill_handler)); + + // Default meta = no protocol version set. + let (peer, _rx) = crate::service::Peer::new( + std::sync::Arc::new(crate::service::AtomicU32RequestIdProvider::default()), + None, + ); + let context = crate::service::RequestContext { + ct: tokio_util::sync::CancellationToken::new(), + id: crate::model::NumberOrString::String("test".to_string().into()), + meta: crate::model::RequestMetaObject::default(), + extensions: crate::model::Extensions::default(), + peer, + }; + + let request = crate::model::ClientRequest::SkillsListRequest( + crate::model::RequestOptionalParam::with_param( + crate::model::PaginatedRequestParams::default(), + ), + ); + + let result = router + .handle_request(request, context) + .await + .expect("must succeed"); + match result { + crate::model::ServerResult::SkillsListResult(r) => { + assert!( + r.ttl_ms.is_none(), + "ttl_ms must be None without protocol version; got {:?}", + r.ttl_ms + ); + assert!( + r.cache_scope.is_none(), + "cache_scope must be None without protocol version; got {:?}", + r.cache_scope + ); + } + other => panic!("expected SkillsListResult, got {:?}", other), + } + } + async fn recv_notification( rx: &mut tokio::sync::mpsc::Receiver>, ) -> ServerNotification { diff --git a/crates/rmcp/src/model/skills.rs b/crates/rmcp/src/model/skills.rs index 8df79e4d8..87a5f5b62 100644 --- a/crates/rmcp/src/model/skills.rs +++ b/crates/rmcp/src/model/skills.rs @@ -53,9 +53,10 @@ pub struct SkillEntry { /// `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. + /// The skill's files: an array of `{uri, digest, size}` triples, or the + /// literal string `"dynamic"` for dynamically-generated skills. + /// REQUIRED by the spec — a host MUST NOT load an entry with `null`. + /// Defaults to `SkillResources::Dynamic` via `SkillEntry::new()`. #[serde(skip_serializing_if = "Option::is_none")] pub resources: Option, @@ -71,7 +72,7 @@ impl SkillEntry { Self { uri: uri.into(), frontmatter, - resources: None, + resources: Some(SkillResources::Dynamic), meta: None, } } @@ -355,7 +356,13 @@ impl ResourcesDirectoryReadResult { /// Parse a `skill://` URI into `(skill_path, file_path)`. /// -/// Per the spec: `skill:///`. +/// **Scope:** This function handles SKILL.md-form route URIs only — URIs of the +/// shape `skill:///SKILL.md`. It is NOT a general-purpose parser for +/// arbitrary nested-file URIs (e.g. `skill://acme/billing/references/FORMS.md`). +/// The spec provides no in-URI delimiter between skill-path and file-path, so +/// splitting on the last `/` is a heuristic that works for the canonical +/// SKILL.md form but cannot disambiguate deeper paths without consulting the +/// registered skill set. /// /// - `` MAY be single-segment (`git-workflow`) or nested /// (`acme/billing/refunds`). @@ -388,12 +395,7 @@ pub fn parse_skill_uri(uri: &str) -> Option<(String, String)> { /// 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) + parse_skill_uri(uri).map(|(path, _file)| path) } /// Extract the skill name (final segment of ``) from a `skill://` URI. 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 70e76f763..662dab7ac 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 @@ -3370,7 +3370,7 @@ "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.", + "description": "The skill's files: an array of `{uri, digest, size}` triples, or the\nliteral string `\"dynamic\"` for dynamically-generated skills.\nREQUIRED by the spec — a host MUST NOT load an entry with `null`.\nDefaults to `SkillResources::Dynamic` via `SkillEntry::new()`.", "anyOf": [ { "$ref": "#/definitions/SkillResources" diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index addd9e9ed..85b5cadd7 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -453,11 +453,19 @@ fn resources_directory_read_lists_siblings() { .and_then(|r| r["result"]["resources"].as_array()) .expect("resources/directory/read should return a resources array"); - // SKILL.md is excluded from the directory listing; only the 2 siblings remain. + // Spec, Directory Listing: "The result contains every direct child of the + // directory." SKILL.md is a file directly inside the skill root, so it IS a + // direct child and MUST be listed. Expect SKILL.md + the 2 siblings = 3. assert_eq!( resources.len(), - 2, - "refunds directory should list 2 sibling files (SKILL.md excluded)" + 3, + "refunds directory should list every direct child (SKILL.md + 2 siblings)" + ); + assert!( + resources + .iter() + .any(|r| r["uri"].as_str().unwrap_or("").ends_with("/SKILL.md")), + "listing must contain SKILL.md — it is a direct child of the skill root" ); assert!( resources @@ -642,12 +650,14 @@ fn macro_server_skills_list_and_get() { /// The macro-generated handler must wire `resources/directory/read` into the /// `ServerHandler` dispatch and advertise the `resources` capability. /// -/// The example declares no file lists — the `#[skill]` macro has no file-list -/// attribute (only `dynamic`) — so an empty array is the correct result. The -/// point of this test is that the method is *answered*, not that it lists -/// files; the hand-written `skill_fs_server` covers the non-empty case. If the -/// macro stops generating `resources_directory_read`, this endpoint falls back -/// to the trait default and returns method-not-found (-32601) instead. +/// The request URIs are **directory** URIs (`skill://alpha`, the skill root), +/// per the spec: the skill root is a directory resource. The example declares +/// no file lists — the `#[skill]` macro has no file-list attribute (only +/// `dynamic`) — so an empty array is the correct result. The point of this test +/// is that the method is *answered*, not that it lists files; the hand-written +/// `skill_fs_server` covers the non-empty case. If the macro stops generating +/// `resources_directory_read`, this endpoint falls back to the trait default +/// and returns method-not-found (-32601) instead. #[test] fn macro_server_resources_directory_read_is_dispatched() { let requests = vec![ @@ -666,17 +676,17 @@ fn macro_server_resources_directory_read_is_dispatched() { "params": {} }) .to_string(), - // Static skill (alpha) — no file list declared. + // Static skill (alpha) — skill root, no file list declared. build_request( 2, "resources/directory/read", - serde_json::json!({"uri": "skill://alpha/SKILL.md"}), + serde_json::json!({"uri": "skill://alpha"}), ), // Dynamic skill (gamma) — macro returns an empty list for `Dynamic`. build_request( 3, "resources/directory/read", - serde_json::json!({"uri": "skill://gamma/SKILL.md"}), + serde_json::json!({"uri": "skill://gamma"}), ), ]; @@ -707,6 +717,62 @@ fn macro_server_resources_directory_read_is_dispatched() { } } +/// A **file** URI (`skill:///SKILL.md`) is not a directory +/// resource, so `resources/directory/read` on it MUST return `-32602` +/// (Invalid params), per the spec: "If the URI does not exist, or exists but is +/// not a directory resource, the server MUST return error `-32602`." +/// +/// Regression guard for the behavior this test previously asserted the +/// opposite of: it used to send `skill://alpha/SKILL.md` and require a +/// `resources` array, which is the non-conforming answer. An unknown skill URI +/// (`skill://nonexistent`) MUST also return `-32602`. +#[test] +fn macro_server_directory_read_rejects_file_and_unknown_uris() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-07-28", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }) + .to_string(), + // A file inside a served skill — not a directory. + build_request( + 2, + "resources/directory/read", + serde_json::json!({"uri": "skill://alpha/SKILL.md"}), + ), + // A URI that does not identify any skill the server serves. + build_request( + 3, + "resources/directory/read", + serde_json::json!({"uri": "skill://nonexistent"}), + ), + ]; + + let responses = run_macro_server_request(&requests); + + for id in [2u64, 3] { + let resp = responses + .iter() + .find(|r| r["id"] == id) + .unwrap_or_else(|| panic!("macro server should answer id {id}")); + assert_eq!( + resp["error"]["code"], + serde_json::json!(-32602), + "expected -32602 (Invalid params) for id {id}; got: {resp}" + ); + } +} + // --------------------------------------------------------------------------- // Regression tests for review findings (4 blocking items) // --------------------------------------------------------------------------- From 40a75f5cc0dec80c9803743be726676423868599 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Tue, 13 Oct 2026 15:13:46 -0700 Subject: [PATCH 21/26] fix(rmcp): stop advertising directoryRead for macro-generated skill servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `#[skill_handler]` macro advertised the SEP-2640 skills extension with `directoryRead: true`, but the macro has no file-list attribute, so its `resources/directory/read` handler returns `{"resources": []}` for every skill. SEP-2640 skills.mdx:63 states: "A server that additionally declares `directoryRead: true` MUST implement `resources/directory/read`" and :557 requires support "for every directory within the skill namespaces it serves as individual files." Always returning empty while advertising the capability violates both. The macro is a compile-time code generator with no runtime filesystem access, so it cannot enumerate. The conformant choice is to not promise the capability. Changes: - capabilities.rs: `insert_skills_extension_into()` takes a `directory_read: bool`; only inserts `"directoryRead": true` when set. Adds `enable_skills_extension_without_directory_read()`. - skill_handler.rs: macro calls the new method; documents the limitation and points users at `#[skill_router]` with explicit `SkillEntry` construction when a populated listing is needed. - test_skills_conformance.rs: asserts the macro server does NOT advertise `directoryRead`; locks in the empty-array behaviour as a characterization test (the macro genuinely has no file list to return). The router path still advertises `directoryRead: true` — correct, because its routes carry explicit `FileList` resources it can enumerate. Verification (all on this commit): - cargo +nightly fmt --all -- --check exit 0 - cargo test -p rmcp --lib 236 passed; 0 failed - cargo test -p rmcp --test test_skill_macros 10 passed; 0 failed - cargo test -p rmcp --test test_skills_conformance 19 passed; 0 failed - cargo clippy --all-targets --all-features -- -D warnings clean - cargo hack clippy -p rmcp --lib --each-feature --exclude-features local -- -D warnings 28/28 clean Does NOT fix: - The macro still cannot enumerate files for skills without a `FileList` route. This change removes the false advertisement, not the limitation. - No `files = [...]` attribute or new API is added. Spec references: specification/stable/skills.mdx lines 58, 60, 63, 64, 555, 557, 797 (extension `io.modelcontextprotocol/skills`, base revision 2026-07-28). --- crates/rmcp-macros/src/skill_handler.rs | 12 +++++- crates/rmcp/src/model/capabilities.rs | 39 ++++++++++++++++---- crates/rmcp/tests/test_skills_conformance.rs | 23 ++++++++++-- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index 9f0b30570..ee285d798 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -48,7 +48,7 @@ pub(crate) fn build_get_info( let mut capability_calls = Vec::new(); if has_skills { - capability_calls.push(quote! { .enable_skills_extension() }); + capability_calls.push(quote! { .enable_skills_extension_without_directory_read() }); capability_calls.push(quote! { .enable_resources() }); } let server_info_expr = match (name, version) { @@ -79,6 +79,16 @@ pub(crate) fn build_get_info( }) } +/// Generates a `ServerHandler` impl with SEP-2640 skills support. +/// +/// **Limitation:** The `#[skill]` attribute has no file-list parameter, so +/// macro-generated skills cannot declare their files. This means: +/// - `SkillEntry::new()` defaults `resources` to `SkillResources::Dynamic` +/// - `resources/directory/read` returns `{"resources": []}` for macro skills +/// - The host cannot content-bind or verify digests +/// +/// Use `#[skill_router]` with explicit `SkillEntry` construction if you need +/// to serve a populated directory listing with content integrity. pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result { let attr_args = NestedMeta::parse_meta_list(attr)?; let SkillHandlerAttribute { diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 0e4013887..1d69a6344 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -265,22 +265,32 @@ impl ServerCapabilities { /// `Router`, so under a `macros`-only build it would be dead code. #[cfg(feature = "server")] pub(crate) fn insert_skills_extension(&mut self) { - insert_skills_extension_into(&mut self.extensions); + insert_skills_extension_into(&mut self.extensions, true); } } /// Write the `io.modelcontextprotocol/skills` extension entry (SEP-2640) into -/// an `extensions` capability map with `directoryRead: true`. +/// an `extensions` capability map. +/// +/// `directory_read: true` advertises support for [`resources/directory/read`]. +/// Pass `false` (or omit) when the server does not implement directory listing +/// for skill directories — clients MUST NOT call `resources/directory/read` +/// without this declaration. /// /// Shared by [`ServerCapabilities::insert_skills_extension`] (in-place) and the /// `enable_skills_extension()` builder method so the two cannot drift. #[cfg(any(feature = "server", feature = "macros"))] -pub(crate) fn insert_skills_extension_into(extensions: &mut Option) { - let mut dir_read = JsonObject::new(); - dir_read.insert("directoryRead".to_string(), serde_json::json!(true)); +pub(crate) fn insert_skills_extension_into( + extensions: &mut Option, + directory_read: bool, +) { + let mut ext = JsonObject::new(); + if directory_read { + ext.insert("directoryRead".to_string(), serde_json::json!(true)); + } extensions .get_or_insert_with(ExtensionCapabilities::new) - .insert(super::SKILLS_EXTENSION_ID.to_string(), dir_read); + .insert(super::SKILLS_EXTENSION_ID.to_string(), ext); } #[cfg(any(feature = "server", feature = "macros"))] @@ -469,8 +479,23 @@ impl ServerCapabilitiesBuilder { impl ServerCapabilitiesBuilder { /// Advertise the `io.modelcontextprotocol/skills` extension (SEP-2640) in the /// `extensions` capability map with `directoryRead: true`. + /// + /// Use [`enable_skills_extension_without_directory_read`](Self::enable_skills_extension_without_directory_read) + /// when the server does not implement `resources/directory/read` for skill + /// directories. pub fn enable_skills_extension(mut self) -> Self { - insert_skills_extension_into(&mut self.extensions); + insert_skills_extension_into(&mut self.extensions, true); + self + } + + /// Advertise the `io.modelcontextprotocol/skills` extension (SEP-2640) in the + /// `extensions` capability map **without** `directoryRead`. + /// + /// Clients MUST NOT call `resources/directory/read` against a server that + /// has not declared `directoryRead: true`. Use this method when the server + /// does not implement directory listing for skill directories. + pub fn enable_skills_extension_without_directory_read(mut self) -> Self { + insert_skills_extension_into(&mut self.extensions, false); self } } diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index 85b5cadd7..eae0ae3bb 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -607,6 +607,15 @@ fn macro_server_skills_list_and_get() { .is_null(), "skills extension must be advertised; got: {init_resp}" ); + // The macro does NOT advertise directoryRead — it cannot enumerate files + // for dynamic skills, and the spec says clients MUST NOT call + // resources/directory/read without the declaration. + assert!( + init_resp["result"]["capabilities"]["extensions"]["io.modelcontextprotocol/skills"] + ["directoryRead"] + .is_null(), + "macro server must NOT advertise directoryRead; got: {init_resp}" + ); // skills/list → 3 skills from the macro server let skills_list = responses @@ -710,9 +719,17 @@ fn macro_server_resources_directory_read_is_dispatched() { resp["error"].is_null(), "resources/directory/read must be dispatched, not method-not-found; got: {resp}" ); - assert!( - resp["result"]["resources"].is_array(), - "resources/directory/read should return a resources array; got: {resp}" + let resources = resp["result"]["resources"].as_array().unwrap_or_else(|| { + panic!("resources/directory/read should return a resources array; got: {resp}") + }); + // Both alpha (static, no file list) and gamma (dynamic) must return + // an empty array — the macro has no file-list attribute, so it cannot + // know what files exist. This assertion locks in the current behavior + // so a future change doesn't silently break it. + assert_eq!( + resources.len(), + 0, + "macro server must return an empty resources array for skills without a file list; got: {resources:?}" ); } } From 2e751cee4843cd65c5744b7b314e5eed2b1756ac Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Tue, 13 Oct 2026 16:28:57 -0700 Subject: [PATCH 22/26] fix(rmcp-macros): emit resources="dynamic" instead of omitting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEP-2640 `skills.mdx:225` states: "`resources` is REQUIRED on every skill entry ... An entry with no `resources` at all, or with any value other than an array or `"dynamic"`, is invalid, and hosts MUST NOT load it." `skill.rs` emitted `quote! { None }` for every skill without `dynamic = true`, and `SkillEntry.resources` carries `#[serde(skip_serializing_if = "Option::is_none")]`, so the field was omitted from the wire entirely. Because `dynamic` is a `bool` defaulting to false, this was the *common* path — the macro's default output was an entry conformant hosts are required to refuse. Measured before the fix (throwaway example, since removed): in-memory resources = true wire json = { "frontmatter": {...}, "uri": "skill://probe/SKILL.md" } has resources key = false VALID PER SPEC = false The generated `skills_list` filtered only on `validate_skill_name`, not on resources validity, so invalid entries were served to clients. The `#[skill]` attribute has no file-list parameter, so the macro cannot publish digests and `"dynamic"` is the only valid value it can emit. The `dynamic` flag is retained for source compatibility; both branches now emit the same value. Changes: - skill.rs: both branches emit `Some(SkillResources::Dynamic)`. - skill_handler.rs: correct the stale doc comment that claimed `SkillEntry::new()`'s default was the value in play (the macro overrode it). - test_skill_macros.rs: assert `resources == Some(Dynamic)` instead of blessing `None`. - test_skills_conformance.rs: assert every entry in the macro server's `skills/list` carries `resources: "dynamic"` — previously unasserted, which is why the invalid state shipped. Verification (all on this commit): - RED, fix reverted with the new tests retained: test_skill_macros → 1 failed: left: None, right: Some(Dynamic) conformance macro → 1 failed: left: Null, right: "dynamic" (failure attributable to the macro change, not the test) - cargo +nightly fmt --all -- --check exit 0 - cargo test -p rmcp --lib 236 passed; 0 failed - cargo test -p rmcp --test test_skill_macros 10 passed; 0 failed - cargo test -p rmcp --test test_skills_conformance 19 passed; 0 failed - cargo clippy --all-targets --all-features -- -D warnings clean - cargo hack clippy -p rmcp --lib --each-feature --exclude-features local -- -D warnings 28/28 clean Does NOT fix: - The macro still cannot publish a file list or digests, so these skills cannot be content-bound and hosts MAY decline them (`skills.mdx:874`). A `files = [...]` parameter would need to emit `include_bytes!` plus a generated `read_resource` to carry honest digests; a bare path list would assert integrity it cannot back. - `resources/directory/read` still returns `{"resources": []}` for macro skills; the macro server still does not advertise `directoryRead`. Spec: specification/stable/skills.mdx:219, :225, :874 (base revision 2026-07-28). --- crates/rmcp-macros/src/skill.rs | 14 +++++++++----- crates/rmcp-macros/src/skill_handler.rs | 11 +++++++---- crates/rmcp/tests/test_skill_macros.rs | 9 +++++++-- crates/rmcp/tests/test_skills_conformance.rs | 11 +++++++++++ 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/crates/rmcp-macros/src/skill.rs b/crates/rmcp-macros/src/skill.rs index b88bb198d..3144f110a 100644 --- a/crates/rmcp-macros/src/skill.rs +++ b/crates/rmcp-macros/src/skill.rs @@ -35,11 +35,15 @@ impl ResolvedSkillAttribute { frontmatter, dynamic, } = self; - let resources = if dynamic { - quote! { Some(rmcp::model::skills::SkillResources::Dynamic) } - } else { - quote! { None } - }; + // SEP-2640 skills.mdx:225 — `resources` is REQUIRED on every skill + // entry, and an entry with no `resources` at all is invalid: hosts + // MUST NOT load it. The `#[skill]` attribute has no file-list + // parameter, so the macro cannot publish digests for a static skill + // and `"dynamic"` is the only valid value it can emit. (The `dynamic` + // flag is retained for source compatibility; both branches emit the + // same value until a file-list parameter exists.) + let _ = dynamic; + let resources = quote! { Some(rmcp::model::skills::SkillResources::Dynamic) }; let doc_comment = format!("Generated skill metadata function for {uri}"); let doc_attr: syn::Attribute = parse_quote!(#[doc = #doc_comment]); let tokens = quote! { diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index ee285d798..d20cf53f5 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -82,10 +82,13 @@ pub(crate) fn build_get_info( /// Generates a `ServerHandler` impl with SEP-2640 skills support. /// /// **Limitation:** The `#[skill]` attribute has no file-list parameter, so -/// macro-generated skills cannot declare their files. This means: -/// - `SkillEntry::new()` defaults `resources` to `SkillResources::Dynamic` -/// - `resources/directory/read` returns `{"resources": []}` for macro skills -/// - The host cannot content-bind or verify digests +/// macro-generated skills cannot declare their files. Every generated entry +/// therefore carries `resources: "dynamic"` — the only value the spec permits +/// when stable digests cannot be published (`skills.mdx:225`; an entry with no +/// `resources` at all is invalid and hosts MUST NOT load it). Consequences: +/// - The host cannot content-bind or verify digests for these skills. +/// - `resources/directory/read` returns `{"resources": []}` for macro skills, +/// so the macro server does not advertise the `directoryRead` capability. /// /// Use `#[skill_router]` with explicit `SkillEntry` construction if you need /// to serve a populated directory listing with content integrity. diff --git a/crates/rmcp/tests/test_skill_macros.rs b/crates/rmcp/tests/test_skill_macros.rs index fd8d0c11c..0e61f2fb4 100644 --- a/crates/rmcp/tests/test_skill_macros.rs +++ b/crates/rmcp/tests/test_skill_macros.rs @@ -50,8 +50,13 @@ fn skill_attr_generates_correct_entry() { assert_eq!(entry.uri, "skill://test-skill/SKILL.md"); assert_eq!(entry.frontmatter["name"], "test-skill"); assert_eq!(entry.frontmatter["description"], "A test skill"); - // Non-dynamic skill with no file list should have None resources - assert!(entry.resources.is_none()); + // SEP-2640 skills.mdx:225 — `resources` is REQUIRED on every skill entry + // and takes one of exactly two forms: a complete file list, or the string + // "dynamic". An entry with no `resources` at all is invalid, and hosts + // MUST NOT load it. The `#[skill]` attribute has no file-list parameter, + // so a non-dynamic skill still cannot publish digests; "dynamic" is the + // only valid value the macro can emit. + assert_eq!(entry.resources, Some(SkillResources::Dynamic)); } #[test] diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index eae0ae3bb..b6ad1a6c5 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -629,6 +629,17 @@ fn macro_server_skills_list_and_get() { 3, "macro server should discover 3 skills (alpha, beta, gamma); got: {skills_list:?}" ); + // SEP-2640 skills.mdx:225 — `resources` is REQUIRED on every skill entry, + // and an entry with no `resources` at all is invalid: hosts MUST NOT load + // it. The `#[skill]` attribute has no file-list parameter, so every + // macro-generated entry must carry the literal string "dynamic" rather + // than omitting the field. + for skill in skills_list { + assert_eq!( + skill["resources"], "dynamic", + "every macro skill entry must carry resources=\"dynamic\"; got: {skill}" + ); + } let uris: Vec<&str> = skills_list .iter() .map(|s| s["uri"].as_str().unwrap_or("")) From 451a02d266e929d5a0191ff8b439b66ff590f79b Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Wed, 14 Oct 2026 05:04:15 -0700 Subject: [PATCH 23/26] fix(rmcp): publish nested skill files and reject non-SKILL.md skills/get URIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two SEP-2640 conformance fixes in the skills extension. resources completeness: the file-system skill server skipped subdirectories when building a skill's `resources` list, so a data file below the skill root (e.g. `billing/refunds/forms/FORMS.md`) was never published and a host could not discover it. The walk now recurses into subdirectories, and every file below the root gets a `skill://` URI relative to the canonical root. skills/get strictness: the handler resolved the request URI through the router's longest-prefix match, which also matches URIs naming a file inside a skill. `skills/get` on `skill://billing/refunds/FORMS.md` therefore returned the skill entry instead of `-32602`. Per spec, `params.uri` MUST be the skill's SKILL.md URI, so the handler now accepts only the skill-root form (`skill://`) or the SKILL.md form and falls through to the default handler otherwise, which answers `-32602` (Invalid params). Tests: three conformance cases added — nested files appear in `resources`; a file URI within a skill is rejected with `-32602`; the canonical SKILL.md URI still resolves. --- crates/rmcp/examples/skill_fs_server.rs | 63 ++++--- crates/rmcp/src/handler/server/router.rs | 34 +++- crates/rmcp/tests/test_skills_conformance.rs | 185 ++++++++++++++++++- 3 files changed, 248 insertions(+), 34 deletions(-) diff --git a/crates/rmcp/examples/skill_fs_server.rs b/crates/rmcp/examples/skill_fs_server.rs index 8b0d95605..d2f1b0ddd 100644 --- a/crates/rmcp/examples/skill_fs_server.rs +++ b/crates/rmcp/examples/skill_fs_server.rs @@ -87,29 +87,30 @@ impl FileSystemSkillServer { let skill_digest = Self::compute_sha256(&canonical_path); resources.push(SkillResource::new(uri.clone(), skill_digest, skill_size)); - // Include sibling files (not subdirs — those are separate skills) - 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; - } - 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)); + // Include all files below the skill root, recursing into subdirectories. + // Spec: "Completeness extends to nested skills. The enclosing skill + // resources lists them too." + let mut files = Vec::new(); + Self::collect_files_recursive(parent_dir, &mut files); + for sibling in files { + if sibling == canonical_path { + continue; + } + let name = sibling.file_name().and_then(|s| s.to_str())?; + if name == SKILL_FILE { + continue; + } + 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() { @@ -128,6 +129,24 @@ impl FileSystemSkillServer { Some(entry) } + /// Recursively collect all files under `dir` into `out`. + /// + /// Used by `parse_skill_file` to enumerate every file below the skill root, + /// including files in nested subdirectories. The spec requires completeness: + /// "The enclosing skill resources lists them too." + fn collect_files_recursive(dir: &Path, out: &mut Vec) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + Self::collect_files_recursive(&path, out); + } else { + out.push(path); + } + } + } + } + /// Compute SHA-256 digest of a file, returning "sha256:" format. fn compute_sha256(path: &Path) -> String { use std::io::Read; diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index 2f760c4ce..3af1e88a4 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -186,15 +186,31 @@ where } 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), - )) + // Spec: params.uri MUST be the skill SKILL.md URI. Reject + // any other file URI within the skill (e.g., FORMS.md) by + // falling through to the default handler, which returns -32602. + let directory_form = route + .skill_path() + .map(|p| request.params.uri == format!("skill://{}", p)) + .unwrap_or(false); + let skill_md_form = crate::model::skills::parse_skill_uri(&request.params.uri) + .map(|(_, file)| file == "SKILL.md") + .unwrap_or(false); + if directory_form || skill_md_form { + 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 + } } else { self.service .handle_request(ClientRequest::SkillsGetRequest(request), context) diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index b6ad1a6c5..19d317e92 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -238,7 +238,7 @@ fn multi_file_skill_lists_siblings() { .map(|r| r["uri"].as_str().unwrap_or("")) .filter(|uri| uri.ends_with(".md")) .collect(); - // After defect 5 fix: SKILL.md + 2 siblings = 3 entries + // SKILL.md + 2 siblings = 3 entries assert_eq!( names.len(), 3, @@ -279,7 +279,7 @@ fn single_file_skill_has_no_resources() { .find(|r| r["id"] == 2) .and_then(|r| r["result"]["skill"]["resources"].as_array()); - // After defect 5 fix: SKILL.md itself is included in resources. + // SKILL.md itself is included in resources. // A single-file skill should have exactly 1 resource (SKILL.md). let resources = resources.expect("single-file skill should have resources"); assert_eq!( @@ -415,7 +415,7 @@ fn multi_file_skill_has_resources_field() { .and_then(|r| r["result"]["skill"]["resources"].as_array()) .expect("multi-file skill should have resources"); - // After defect 5 fix: SKILL.md + 2 siblings = 3 entries + // SKILL.md + 2 siblings = 3 entries assert_eq!( resources.len(), 3, @@ -801,6 +801,185 @@ fn macro_server_directory_read_rejects_file_and_unknown_uris() { } } +// --------------------------------------------------------------------------- +// Tests for SEP-2640 spec conformance fixes +// --------------------------------------------------------------------------- + +/// Resources completeness: files below the skill root must be published, even +/// in subdirectories that are not themselves skills. +/// +/// Builds a skill tree where a data file lives in a subdirectory below the +/// skill root (`billing/refunds/forms/FORMS.md`) and verifies `skills/get` +/// lists it. Before the fix the walk skipped directories, so the file was +/// invisible to a host. +#[test] +fn nested_skill_subdirectory_files_are_published_in_resources() { + use std::fs; + + // Build a temp skill tree: + // tmp_root/ + // billing/ + // refunds/ + // SKILL.md + // forms/ + // FORMS.md + let tmp = std::env::temp_dir().join(format!("rmcp_test_{}", std::process::id())); + let skill_dir = tmp.join("billing").join("refunds"); + let sub_dir = skill_dir.join("forms"); + fs::create_dir_all(&sub_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: refunds\ndescription: Refund handling\n---\n# Refunds\n", + ) + .unwrap(); + fs::write(sub_dir.join("FORMS.md"), "# Refund Forms\n\nForm 101\n").unwrap(); + + // Run the server against the temp root + let mut child = Command::new("cargo") + .args([ + "run", + "--example", + "skill_fs_server", + "--features", + "server,transport-io", + "--", + "--root", + ]) + .arg(&tmp) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to spawn server"); + + 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 stdin = child.stdin.as_mut().expect("failed to open stdin"); + for req in &requests { + writeln!(stdin, "{}", req).expect("failed to write request"); + } + } + + let output = child.wait_with_output().expect("failed to wait for server"); + let stdout = String::from_utf8_lossy(&output.stdout); + let responses: Vec = stdout + .lines() + .filter_map(|line| serde_json::from_str(line.trim()).ok()) + .collect(); + + // Cleanup + let _ = fs::remove_dir_all(&tmp); + + let resources = responses + .iter() + .find(|r| r["id"] == 2) + .and_then(|r| r["result"]["skill"]["resources"].as_array()) + .expect("should have resources for nested skill"); + + assert!( + resources.len() >= 2, + "should include SKILL.md + nested FORMS.md; got {} resources: {:?}", + resources.len(), + resources + ); + assert!( + resources + .iter() + .any(|r| r["uri"].as_str().unwrap_or("").ends_with("/FORMS.md")), + "should contain nested FORMS.md in resources" + ); +} + +/// `skills/get` must reject a URI naming a file *inside* a skill. +/// +/// Spec: `params.uri` MUST be the skill's SKILL.md URI. The handler used to +/// prefix-match through the router, so a request for +/// `skill://billing/refunds/FORMS.md` returned the skill entry instead of +/// `-32602` (Invalid params). +#[test] +fn skills_get_rejects_file_uri_within_skill() { + 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/FORMS.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 non-SKILL.md URI"); + + assert_eq!( + error["code"], + serde_json::json!(-32602), + "skills/get on a non-SKILL.md URI must return -32602; got: {error:?}" + ); +} + +/// `skills/get` must still resolve the canonical SKILL.md URI. +/// +/// Regression guard: tightening the check must not break the happy path of +/// `skill://billing/refunds/SKILL.md`. +#[test] +fn skills_get_accepts_skill_md_uri() { + 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 for SKILL.md URI"); + + assert_eq!( + skill["uri"], "skill://billing/refunds/SKILL.md", + "skills/get on SKILL.md URI must succeed" + ); +} + // --------------------------------------------------------------------------- // Regression tests for review findings (4 blocking items) // --------------------------------------------------------------------------- From a293192c87ce15a310dbb7a35cfa71fc4f9a748b Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Wed, 14 Oct 2026 10:44:55 -0700 Subject: [PATCH 24/26] fix(rmcp): close spec conformance gaps in skills extension Three fixes verified with RED->GREEN tests: 1. Macro skills_get gate: replaced 12-line directory_form||skill_md_form check with 2-line equality gate (request.uri != route.attr.uri) on both macro and Router paths. Closes two holes: non-SKILL.md canonical URIs were listed but not gettable; nested SKILL.md URIs returned the wrong skill's entry. 2. SkillsGetResult cache hints: added ttl_ms/cache_scope fields and included SkillsGetResult in fill_missing_cache_hints match. Spec:419 requires these on every skills/get response. 3. #[skill] macro compile-time URI validation: added /SKILL.md suffix check so non-SKILL.md URIs fail at compile time, not at runtime. Verification: 26/26 conformance, 10/10 macro, 940/0 full suite, clippy clean, fmt clean. --- crates/rmcp-macros/src/skill.rs | 9 + crates/rmcp-macros/src/skill_handler.rs | 11 + crates/rmcp/src/handler/server/router.rs | 15 +- crates/rmcp/src/model.rs | 9 +- crates/rmcp/src/model/skills.rs | 11 +- crates/rmcp/tests/test_skills_conformance.rs | 201 +++++++++++++++++++ 6 files changed, 241 insertions(+), 15 deletions(-) diff --git a/crates/rmcp-macros/src/skill.rs b/crates/rmcp-macros/src/skill.rs index 3144f110a..a59cf1ce1 100644 --- a/crates/rmcp-macros/src/skill.rs +++ b/crates/rmcp-macros/src/skill.rs @@ -105,6 +105,15 @@ pub fn skill(attr: TokenStream, input: TokenStream) -> syn::Result )); } + // Spec:415 — `params.uri` MUST be the URI of a skill's `SKILL.md`. + // Enforce at compile time so invalid URIs fail before deployment. + if !stripped.ends_with("/SKILL.md") { + return Err(syn::Error::new_spanned( + fn_ident, + format!("`#[skill]` URI must end with `/SKILL.md` (got `{uri}`)"), + )); + } + // Validate frontmatter is present let frontmatter = attribute.frontmatter.ok_or_else(|| { syn::Error::new_spanned( diff --git a/crates/rmcp-macros/src/skill_handler.rs b/crates/rmcp-macros/src/skill_handler.rs index d20cf53f5..1cf99e23e 100644 --- a/crates/rmcp-macros/src/skill_handler.rs +++ b/crates/rmcp-macros/src/skill_handler.rs @@ -145,6 +145,17 @@ pub fn skill_handler(attr: TokenStream, input: TokenStream) -> syn::Result { if let Some(route) = self.skill_router.get_by_uri(&request.params.uri) { // Spec: params.uri MUST be the skill SKILL.md URI. Reject - // any other file URI within the skill (e.g., FORMS.md) by - // falling through to the default handler, which returns -32602. - let directory_form = route - .skill_path() - .map(|p| request.params.uri == format!("skill://{}", p)) - .unwrap_or(false); - let skill_md_form = crate::model::skills::parse_skill_uri(&request.params.uri) - .map(|(_, file)| file == "SKILL.md") - .unwrap_or(false); - if directory_form || skill_md_form { + // any other URI with -32602. The equality check subsumes both + // the directory-form and skill-md-form checks and also rejects + // nested SKILL.md URIs that are not the canonical URI of any + // registered skill. + if request.params.uri == route.attr.uri { let skill_context = crate::handler::server::skill::SkillCallContext::new( self.service.as_ref(), request.params.uri.clone(), diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index ce5c743bf..618d00891 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -4879,8 +4879,13 @@ impl ServerResult { ServerResult::ReadResourceResult(r) => { (&r.result_type, &mut r.ttl_ms, &mut r.cache_scope) } - // SEP-2640 skill results with `Option` cache_scope. - // `SkillsGetResult` is absent on purpose: it has no cache fields. + ServerResult::SkillsGetResult(r) => { + return fill_missing_cache_hints_for_string_scope( + &r.result_type, + &mut r.ttl_ms, + &mut r.cache_scope, + ); + } ServerResult::SkillsListResult(r) => { return fill_missing_cache_hints_for_string_scope( &r.result_type, diff --git a/crates/rmcp/src/model/skills.rs b/crates/rmcp/src/model/skills.rs index 87a5f5b62..31ef44e55 100644 --- a/crates/rmcp/src/model/skills.rs +++ b/crates/rmcp/src/model/skills.rs @@ -250,9 +250,8 @@ pub type SkillsGetRequest = Request, pub skill: SkillEntry, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, } impl SkillsGetResult { @@ -268,6 +271,8 @@ impl SkillsGetResult { Self { result_type: Some(ResultType::COMPLETE), skill, + ttl_ms: None, + cache_scope: None, } } } diff --git a/crates/rmcp/tests/test_skills_conformance.rs b/crates/rmcp/tests/test_skills_conformance.rs index 19d317e92..185c8885c 100644 --- a/crates/rmcp/tests/test_skills_conformance.rs +++ b/crates/rmcp/tests/test_skills_conformance.rs @@ -805,6 +805,207 @@ fn macro_server_directory_read_rejects_file_and_unknown_uris() { // Tests for SEP-2640 spec conformance fixes // --------------------------------------------------------------------------- +/// `skills/get` on the **macro-generated** handler must reject a URI naming a +/// file *inside* a skill, not just the Router path. +/// +/// Spec: `params.uri` MUST be the skill's SKILL.md URI. `SkillRouter::get_by_uri` +/// matches by longest prefix, so `skill://alpha/appendix.md` resolves to the +/// `alpha` route — the macro-generated `skills_get` used to call straight +/// through and return the skill entry instead of `-32602` (Invalid params). +/// The hand-written Router path had this gate; the macro path did not. +/// +/// The sibling test `skills_get_rejects_file_uri_within_skill` covers the same +/// rule on the hand-written `skill_fs_server`, so this is the macro counterpart. +#[test] +fn macro_server_skills_get_rejects_non_skill_md_uri() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }) + .to_string(), + // A sibling file inside the served skill — NOT the SKILL.md URI. + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://alpha/appendix.md"}), + ), + // A file nested below the skill root — also NOT the SKILL.md URI. + build_request( + 3, + "skills/get", + serde_json::json!({"uri": "skill://beta/nested/refs/x.md"}), + ), + // The canonical URI must still resolve, so the gate is not over-broad. + build_request( + 4, + "skills/get", + serde_json::json!({"uri": "skill://alpha/SKILL.md"}), + ), + ]; + + let responses = run_macro_server_request(&requests); + + for id in [2u64, 3] { + let resp = responses + .iter() + .find(|r| r["id"] == id) + .unwrap_or_else(|| panic!("macro server should answer skills/get id {id}")); + assert_eq!( + resp["error"]["code"], + serde_json::json!(-32602), + "skills/get on a non-SKILL.md URI must return -32602 (Invalid params); got: {resp}" + ); + assert!( + resp["result"].is_null(), + "a rejected skills/get must not also carry a result; got: {resp}" + ); + } + + // Happy path unaffected: the SKILL.md URI still returns the entry. + let ok = responses + .iter() + .find(|r| r["id"] == 4) + .expect("macro server should answer skills/get id 4"); + assert!( + ok["error"].is_null(), + "skills/get on the canonical SKILL.md URI must succeed; got: {ok}" + ); + assert_eq!( + ok["result"]["skill"]["uri"], "skill://alpha/SKILL.md", + "canonical skills/get should return the alpha entry; got: {ok}" + ); +} + +/// Hole (b): a nested SKILL.md URI that is not the canonical URI of any +/// registered skill must be rejected. The `directory_form || skill_md_form` +/// gate passes it because `parse_skill_uri` returns `("beta/nested/sub", "SKILL.md")` +/// and `file == "SKILL.md"` is true. The 2-line equality gate rejects it because +/// `"skill://beta/nested/sub/SKILL.md" != "skill://beta/nested/SKILL.md"`. +#[test] +fn macro_server_skills_get_rejects_nested_skill_md_uri() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }) + .to_string(), + // A nested SKILL.md URI that is not the canonical URI of any skill. + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://beta/nested/sub/SKILL.md"}), + ), + ]; + + let responses = run_macro_server_request(&requests); + + let resp = responses + .iter() + .find(|r| r["id"] == 2) + .expect("macro server should answer skills/get id 2"); + assert_eq!( + resp["error"]["code"], + serde_json::json!(-32602), + "skills/get on a nested SKILL.md URI must return -32602; got: {resp}" + ); +} + +/// Spec:419 — `GetSkillResult` extends `CacheableResult`, so `ttlMs` and +/// `cacheScope` are REQUIRED on every `skills/get` response. +/// +/// This is a unit test on `fill_missing_cache_hints` (same pattern as +/// `fill_missing_cache_hints_covers_skills_list_result`), not an integration +/// test through the server, because the server gates cache hints on the +/// negotiated protocol version (2026-07-28+) and the `initialize` handshake +/// negotiates down to 2025-11-25 when the client requests 2026-07-28. +#[test] +fn fill_missing_cache_hints_covers_skills_get_result() { + use rmcp::model::{ResultType, ServerResult, SkillEntry, skills::SkillsGetResult}; + + let entry = SkillEntry::new( + "skill://test/SKILL.md", + serde_json::json!({ + "name": "test", + "description": "Test skill" + }), + ); + let mut result = SkillsGetResult::new(entry); + result.result_type = Some(ResultType::COMPLETE); + result.ttl_ms = None; + result.cache_scope = None; + + let mut server_result = ServerResult::SkillsGetResult(result); + server_result.fill_missing_cache_hints(); + + let ServerResult::SkillsGetResult(r) = &server_result else { + panic!("expected SkillsGetResult"); + }; + assert!( + r.ttl_ms.is_some(), + "fill_missing_cache_hints must set ttl_ms on SkillsGetResult; got: {r:?}" + ); + assert!( + r.cache_scope.is_some(), + "fill_missing_cache_hints must set cache_scope on SkillsGetResult; got: {r:?}" + ); +} + +/// Hole (b) on the Router path: a nested SKILL.md URI that is not the +/// canonical URI of any registered skill must be rejected. +#[test] +fn skills_get_rejects_nested_skill_md_uri() { + let requests = vec![ + build_request( + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2026-03-26", + "capabilities": {"skills": {}}, + "clientInfo": {"name": "test", "version": "0.1.0"} + }), + ), + // A nested SKILL.md URI that is not the canonical URI of any skill. + build_request( + 2, + "skills/get", + serde_json::json!({"uri": "skill://billing/refunds/forms/SKILL.md"}), + ), + ]; + + let responses = run_server_request(&requests); + + let resp = responses + .iter() + .find(|r| r["id"] == 2) + .expect("server should answer skills/get id 2"); + assert_eq!( + resp["error"]["code"], + serde_json::json!(-32602), + "skills/get on a nested SKILL.md URI must return -32602; got: {resp}" + ); +} + /// Resources completeness: files below the skill root must be published, even /// in subdirectories that are not themselves skills. /// From e2b4838f3494fb078b1c1adff5e55dfdb2786837 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Wed, 14 Oct 2026 10:52:26 -0700 Subject: [PATCH 25/26] chore: update JSON schema snapshot for SkillsGetResult cache hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ttl_ms/cache_scope fields added to SkillsGetResult change the ServerJsonRpcMessage schema. Regenerate the snapshot. Verification: UPDATE_SCHEMA=1 cargo test -p rmcp --test test_message_schema --features 'server client schemars' — 3/3 passed --- .../server_json_rpc_message_schema.json | 16 ++++++++++++++- ...erver_json_rpc_message_schema_current.json | 20 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) 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 662dab7ac..bebd54a83 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 @@ -3439,9 +3439,15 @@ ] }, "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.", + "description": "Response to `skills/get`.\n\nSpec:419 — `GetSkillResult` extends `CacheableResult`, so `ttlMs` and\n`cacheScope` are REQUIRED on every `skills/get` response.", "type": "object", "properties": { + "cacheScope": { + "type": [ + "string", + "null" + ] + }, "resultType": { "anyOf": [ { @@ -3454,6 +3460,14 @@ }, "skill": { "$ref": "#/definitions/SkillEntry" + }, + "ttlMs": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ 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 404c1b0f2..bebd54a83 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." } } }, @@ -3370,7 +3370,7 @@ "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.", + "description": "The skill's files: an array of `{uri, digest, size}` triples, or the\nliteral string `\"dynamic\"` for dynamically-generated skills.\nREQUIRED by the spec — a host MUST NOT load an entry with `null`.\nDefaults to `SkillResources::Dynamic` via `SkillEntry::new()`.", "anyOf": [ { "$ref": "#/definitions/SkillResources" @@ -3439,9 +3439,15 @@ ] }, "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.", + "description": "Response to `skills/get`.\n\nSpec:419 — `GetSkillResult` extends `CacheableResult`, so `ttlMs` and\n`cacheScope` are REQUIRED on every `skills/get` response.", "type": "object", "properties": { + "cacheScope": { + "type": [ + "string", + "null" + ] + }, "resultType": { "anyOf": [ { @@ -3454,6 +3460,14 @@ }, "skill": { "$ref": "#/definitions/SkillEntry" + }, + "ttlMs": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ From 60fc35af283b38d3f58546b4bd7addbd31e8b310 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Wed, 14 Oct 2026 12:22:40 -0700 Subject: [PATCH 26/26] fix(rmcp): align SkillsGetResult doc and cache scope with spec Doc comment claimed 'REQUIRED on every skills/get response' citing Spec:419, but the merged SEP-2640 leaves this open. Updated to reflect actual spec language: fields provided for parity with SkillsListResult, present only when negotiated protocol version is 2026-07-28+. Cache scope was 'private' in fill_missing_cache_hints but 'public' in router.rs for SkillsListResult. Aligned to 'public' for consistency. Verification: 26/26 conformance, 10/10 macro, 3/3 schema, clippy clean, fmt clean --- crates/rmcp/src/model.rs | 2 +- crates/rmcp/src/model/skills.rs | 6 ++++-- .../test_message_schema/server_json_rpc_message_schema.json | 2 +- .../server_json_rpc_message_schema_current.json | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 618d00891..a7cd20a82 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -4923,7 +4923,7 @@ fn fill_missing_cache_hints_for_string_scope( return; } ttl_ms.get_or_insert(0); - cache_scope.get_or_insert("private".to_string()); + cache_scope.get_or_insert("public".to_string()); } pub type ServerJsonRpcMessage = JsonRpcMessage; diff --git a/crates/rmcp/src/model/skills.rs b/crates/rmcp/src/model/skills.rs index 31ef44e55..ca90f3ff0 100644 --- a/crates/rmcp/src/model/skills.rs +++ b/crates/rmcp/src/model/skills.rs @@ -250,8 +250,10 @@ pub type SkillsGetRequest = Request