Repository navigation
feat(rmcp): implement SEP-2640 MCP Skills Extension #1286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+5,368
−33
Closed
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
72334ff
feat(rmcp): implement SEP-2640 skills macros + file-system demo server
8c7465c
chore(rmcp): fix clippy warnings and formatting
83d1097
test: update message schema snapshots for SEP-2640 skills requests
fcaef81
fix(rmcp-macros): export skill macro trio + fix expansion bugs
752882d
fix(model): remove skill requests from ServerRequest union
45f82bf
fix(rmcp): declare SEP-2640 skills capability + correct skills/get er…
d225761
fix(rmcp-macros): skills/get returns handler's SkillEntry with frontm…
07587b3
fix(rmcp): wire format, verbatim YAML, resources completeness, direct…
f51601e
chore(rmcp): remove allow(dead_code), regenerate schema, add macro tests
394627d
fix(rmcp): defect 2 (URI prefix match) + subdirectory bug + macro tes…
959fc25
fix(rmcp): drop top-level skills capability field, use extension map
90ffc87
test(rmcp): add real resources/directory/read + resources/read confor…
d235c45
fix(rmcp): hold RunningService in skill_macro_server example + wire t…
25d3051
fix(rmcp): gate `Cow` import in skills.rs behind `schemars`
73408a2
Merge upstream/main into feat/sep-2640-skills
5b8b3de
fix(rmcp): route skill directory reads through the skill catalog
8a44b8f
fix(rmcp): fill cache hints on skill list results
074fb95
fix(rmcp): advertise skills extension in the fs example server
0c8d1a9
fix(rmcp): remove crate-wide allow(dead_code) and address SEP-2640 re…
caa4eab
fix(rmcp): resolve ten SEP-2640 spec conformance defects
40a75f5
fix(rmcp): stop advertising directoryRead for macro-generated skill s…
2e751ce
fix(rmcp-macros): emit resources="dynamic" instead of omitting it
451a02d
fix(rmcp): publish nested skill files and reject non-SKILL.md skills/…
a293192
fix(rmcp): close spec conformance gaps in skills extension
e2b4838
chore: update JSON schema snapshot for SkillsGetResult cache hints
60fc35a
fix(rmcp): align SkillsGetResult doc and cache scope with spec
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| } | ||
|
|
||
| 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 | ||
| }) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dynamicis accepted but silently ignored by thelet _ = dynamic;below. Also, the note about retaining it for source compatibility doesn't apply to a macro introduced in this PR.There was a problem hiding this comment.
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