Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
85a50f7
feat(rmcp): implement SEP-2640 MCP Skills Extension
Sep 15, 2026
72334ff
feat(rmcp): implement SEP-2640 skills macros + file-system demo server
Sep 18, 2026
8c7465c
chore(rmcp): fix clippy warnings and formatting
Sep 19, 2026
83d1097
test: update message schema snapshots for SEP-2640 skills requests
Sep 23, 2026
fcaef81
fix(rmcp-macros): export skill macro trio + fix expansion bugs
Oct 10, 2026
752882d
fix(model): remove skill requests from ServerRequest union
Oct 10, 2026
45f82bf
fix(rmcp): declare SEP-2640 skills capability + correct skills/get er…
Oct 10, 2026
d225761
fix(rmcp-macros): skills/get returns handler's SkillEntry with frontm…
Oct 10, 2026
07587b3
fix(rmcp): wire format, verbatim YAML, resources completeness, direct…
Oct 10, 2026
f51601e
chore(rmcp): remove allow(dead_code), regenerate schema, add macro tests
Oct 11, 2026
394627d
fix(rmcp): defect 2 (URI prefix match) + subdirectory bug + macro tes…
Oct 10, 2026
959fc25
fix(rmcp): drop top-level skills capability field, use extension map
Oct 11, 2026
90ffc87
test(rmcp): add real resources/directory/read + resources/read confor…
Oct 11, 2026
d235c45
fix(rmcp): hold RunningService in skill_macro_server example + wire t…
Oct 11, 2026
25d3051
fix(rmcp): gate `Cow` import in skills.rs behind `schemars`
Oct 11, 2026
73408a2
Merge upstream/main into feat/sep-2640-skills
Oct 11, 2026
5b8b3de
fix(rmcp): route skill directory reads through the skill catalog
Oct 12, 2026
8a44b8f
fix(rmcp): fill cache hints on skill list results
Oct 12, 2026
074fb95
fix(rmcp): advertise skills extension in the fs example server
Oct 12, 2026
0c8d1a9
fix(rmcp): remove crate-wide allow(dead_code) and address SEP-2640 re…
Oct 13, 2026
caa4eab
fix(rmcp): resolve ten SEP-2640 spec conformance defects
Oct 13, 2026
40a75f5
fix(rmcp): stop advertising directoryRead for macro-generated skill s…
Oct 13, 2026
2e751ce
fix(rmcp-macros): emit resources="dynamic" instead of omitting it
Oct 13, 2026
451a02d
fix(rmcp): publish nested skill files and reject non-SKILL.md skills/…
Oct 14, 2026
a293192
fix(rmcp): close spec conformance gaps in skills extension
Oct 14, 2026
e2b4838
chore: update JSON schema snapshot for SkillsGetResult cache hints
Oct 14, 2026
60fc35a
fix(rmcp): align SkillsGetResult doc and cache scope with spec
Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions crates/rmcp-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -330,3 +333,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<Box<Future>>`.
#[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_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())
.unwrap_or_else(|err| err.to_compile_error())
.into()
}
176 changes: 176 additions & 0 deletions crates/rmcp-macros/src/skill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! 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<Box<Future>>` so the gradebook can call it synchronously.

use darling::{FromMeta, ast::NestedMeta};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{Expr, Ident, ImplItemFn, parse_quote};

#[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<String>,
/// Path to the frontmatter JSON (typically `include_str!("...frontmatter.json")`).
pub frontmatter: Option<Expr>,
/// Whether this skill generates its content dynamically (no fixed file list).
pub dynamic: bool,
Comment on lines +21 to +22

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dynamic is accepted but silently ignored by the let _ = dynamic; below. Also, the note about retaining it for source compatibility doesn't apply to a macro introduced in this PR.

Suggested change
/// Whether this skill generates its content dynamically (no fixed file list).
pub dynamic: bool,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1
dropping the dead param because it doesn't apply here

}

pub struct ResolvedSkillAttribute {
pub uri: String,
pub frontmatter: Expr,
pub dynamic: bool,
}

impl ResolvedSkillAttribute {
pub fn into_fn(self, fn_ident: Ident) -> syn::Result<ImplItemFn> {
let Self {
uri,
frontmatter,
dynamic,
} = self;
// 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! {
#doc_attr
pub fn #fn_ident() -> rmcp::model::skills::SkillEntry {
let mut entry = rmcp::model::skills::SkillEntry::new(#uri, #frontmatter);
entry.resources = #resources;
entry
}
};
syn::parse2::<ImplItemFn>(tokens)
}
}

pub fn skill(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
let attribute = if attr.is_empty() {
Default::default()
} else {
let attr_args = NestedMeta::parse_meta_list(attr)?;
SkillAttribute::from_list(&attr_args)?
};
let fn_item = syn::parse2::<ImplItemFn>(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}`)"
),
));
}

// 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(
fn_ident,
"`#[skill]` attribute requires a `frontmatter` parameter, e.g. `#[skill(frontmatter = include_str!(\"frontmatter.json\"))]`",
)
})?;

let resolved = ResolvedSkillAttribute {
uri,
frontmatter,
dynamic: attribute.dynamic,
};
let skill_attr_fn = resolved.into_fn(skill_attr_fn_ident.clone())?;

// 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<S> and returns Result<SkillEntry, ErrorData>.
// 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<Box<dyn ::std::future::Future<Output = Result<rmcp::model::skills::SkillEntry, rmcp::ErrorData>> + Send + 'static>> {
let this = self.clone();
Box::pin(async move {
let _ = ctx.service;
this.#fn_ident().await?;
Ok(Self::#skill_attr_fn_ident_clone())
})
}
}
} else {
quote! {
#[allow(clippy::too_many_arguments)]
fn #handler_fn_ident(
&self,
_ctx: rmcp::handler::server::skill::SkillCallContext<'_, Self>,
) -> ::std::pin::Pin<Box<dyn ::std::future::Future<Output = Result<rmcp::model::skills::SkillEntry, rmcp::ErrorData>> + Send + 'static>> {
let this = self.clone();
Box::pin(async move {
this.#fn_ident()?;
Ok(Self::#skill_attr_fn_ident_clone())
})
}
}
};

Ok(quote! {
#skill_attr_fn
#fn_item
#handler_fn
})
}
Loading
Loading