Repository navigation
Conversation
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<RoleClient> 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.
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<S> + SkillRoute<S> 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.
aba85c0 to
15ed73a
Compare
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.
15ed73a to
2585f28
Compare
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.
alexhancock
left a comment
There was a problem hiding this comment.
Thanks for looking into this. It needs refinement in a few areas
A list I found from an AI assisted review:
- No extension declaration: servers never advertise
io.modelcontextprotocol/skills,directoryRead, or theresourcescapability, so compliant clients won't call any skill methods. resourcestreated as optional: entries can omit it, and the demo leaves outSKILL.mdand subdirectory files. The spec requires a complete list.- Wrong directory-read wire format: it returns
childrenwithisDirectoryinstead ofresourceswithmimeType: "inode/directory". - Directory read misbehaves:
Routeralways returns an empty list, the demo lists the parent folder, and missing paths don't return-32602. skills/gettoo loose: it accepts any file URI inside a skill, returns-32601instead of-32602when nothing matches, and the macro version drops the frontmatter and resources.- URI parsing bug:
parse_skill_urigets the skill path and name wrong for supporting files in subfolders. - Frontmatter not copied exactly: the demo's flat-YAML parser mangles nested fields like
metadata. - Requests in the wrong direction: the skill requests were also added to
ServerRequest, the server-to-client list. - Dead macros: the skill macros are never exported, a crate-wide
allow(dead_code)hides this, and their generated code wouldn't compile.
| @@ -1,4 +1,5 @@ | |||
| #![doc = include_str!("../README.md")] | |||
| #![allow(dead_code)] | |||
There was a problem hiding this comment.
Why are we adding this?
There was a problem hiding this comment.
I set a slash goal for this implementation and instead of actually reading the logs my AI assisted review for this looked to simply use this as a temporary bandaid instead of literally using clippy and forgot to remove it. This should be a note that simply "passing tests" isn't enough! I've gone ahead and removed this in an upcoming commit
the skill macros were pub fn inside a private module
| ) | ||
| .await | ||
| .map(ClientResult::CustomResult), | ||
| ServerRequest::SkillsListRequest(request) => self |
There was a problem hiding this comment.
This is the wrong direction no? ServerRequest holds requests the server makes to the client.
There was a problem hiding this comment.
I misunderstood this and mentally had it the opposite! noted, and adjusted in my upcoming commit
client logic ended up in the wrong union
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<SkillEntry, ErrorData> 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)
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)
…ror code
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 ✓
…atter
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)
…ory errors Defects 4-7 + read_resource override: Defect 6 (wire format): Changed ResourcesDirectoryReadResult from children: Vec<DirectoryEntry> to resources: Vec<Resource> 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
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.
…t coverage 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
884623b to
301eac9
Compare
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.
…mance 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).
…ests 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.
`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 modelcontextprotocol#1314 fixes it, so it does not appear on CI's merge commit.)
Brings the branch current with modelcontextprotocol/rust-sdk main (was 42 commits behind). Conflict-free per `git merge-tree --write-tree`. Motivation: CI runs clippy/tests on the merge commit, so the branch was already being linted with main's newer, stricter `cargo hack --lib --each-feature` clippy matrix while its own ci.yml lacked that step. Merging makes the local gate match what CI actually runs. Inherited fix included: upstream modelcontextprotocol#1314 (prompt.rs AudioContent/ImageContent unused imports), which is why that error showed in local reduced-feature clippy but never on CI.
745c669 to
73408a2
Compare
Hey Alex, appreciate you checking this one out! I gave a pass at your feedback and I've got a follow up commit for each bullet. I'm listing it as "addressed in code" not "resolved". While working with AI on this solution I determined I needed a more genuine end to end test, so I built a real MCP consumer around a skill I currently have as a reference. Here's the documentation with a timeline I listed all the areas my agent deviated during these commits, why it failed and what I did as an alternative. |
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://<path>`, no `/SKILL.md`) also failed the
`starts_with("skill://<path>/")` 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.
`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<CacheScope>` on the existing results but `Option<String>` 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<String>`).
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.
|
follow up testing revealed some more defects that I've addressed in these commits:
[HALT] Each fix has a dedicated regression test that was verified RED before the fix and GREEN after, plus 5 new tests across unit and integration levels. I've also updated the linked documentation above |
…view findings Non-breaking cleanups from the modelcontextprotocol#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<String>`; 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.
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<String>, 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.
…ervers
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).
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).
…get URIs 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://<skill-path>`) 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.
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.
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
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
| // Spec: the method applies only to directory resources. The | ||
| // skill root (`skill://<skill-path>`) is a directory; a file | ||
| // URI such as `skill://<skill-path>/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<crate::model::Resource> = match &route.attr.resources { | ||
| 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); |
There was a problem hiding this comment.
The D2 test below quotes the spec — "clients descend by calling the method again on a child directory" — but a child-directory URI like skill://pdf-processing/templates matches the route by prefix and then fails this equality gate with -32602, so the subdirectories this very listing advertises as inode/directory can never be read. Could the gate treat a URI as a directory when some declared file lives under it, and list relative to the requested URI (the same gate in the skill_handler.rs macro expansion would need the same change)?
| // Spec: the method applies only to directory resources. The | |
| // skill root (`skill://<skill-path>`) is a directory; a file | |
| // URI such as `skill://<skill-path>/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<crate::model::Resource> = match &route.attr.resources { | |
| 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); | |
| // Spec: the method applies only to directory resources. The | |
| // skill root (`skill://<skill-path>`) is a directory, and so | |
| // is any subdirectory that declared files live under; a file | |
| // URI such as `skill://<skill-path>/SKILL.md` is not, and | |
| // MUST return -32602. | |
| let directory_form = | |
| format!("skill://{}", route.skill_path().unwrap_or_default()); | |
| let subdir_prefix = format!("{}/", request.params.uri); | |
| let is_directory = request.params.uri == directory_form | |
| || route | |
| .attr | |
| .resources | |
| .as_ref() | |
| .and_then(|r| r.as_files()) | |
| .is_some_and(|files| { | |
| files.iter().any(|f| f.uri.starts_with(&subdir_prefix)) | |
| }); | |
| if !is_directory { | |
| return Err(crate::ErrorData::invalid_params( | |
| format!("not a directory: {}", request.params.uri), | |
| None, | |
| )); | |
| } | |
| let resources: Vec<crate::model::Resource> = match &route.attr.resources { | |
| 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 = request.params.uri.as_str(); | |
| let prefix = format!("{}/", root); |
There was a problem hiding this comment.
treat a URI as a subdirectory when a declared file lives under it. This logic should go in both router.rs and the macro expansion!
| let result = self | ||
| .send_request(ClientRequest::SkillsGetRequest(SkillsGetRequest::new( | ||
| params, | ||
| ))) | ||
| .await; | ||
| match result { | ||
| Ok(ServerResult::SkillsGetResult(result)) => Ok(result), | ||
| _ => Err(ServiceError::UnexpectedResponse), | ||
| } |
There was a problem hiding this comment.
A server-side error, like -32602 for a missing skill, comes through here as Err(ServiceError::McpError(...)). The catch-all turns it into UnexpectedResponse, so we lose the real error.
| let result = self | |
| .send_request(ClientRequest::SkillsGetRequest(SkillsGetRequest::new( | |
| params, | |
| ))) | |
| .await; | |
| match result { | |
| Ok(ServerResult::SkillsGetResult(result)) => Ok(result), | |
| _ => Err(ServiceError::UnexpectedResponse), | |
| } | |
| let result = self | |
| .send_request(ClientRequest::SkillsGetRequest(SkillsGetRequest::new( | |
| params, | |
| ))) | |
| .await?; | |
| match result { | |
| ServerResult::SkillsGetResult(result) => Ok(result), | |
| _ => Err(ServiceError::UnexpectedResponse), | |
| } |
| let result = self | ||
| .send_request(ClientRequest::ResourcesDirectoryReadRequest( | ||
| ResourcesDirectoryReadRequest::new(params), | ||
| )) | ||
| .await; | ||
| match result { | ||
| Ok(ServerResult::ResourcesDirectoryReadResult(result)) => Ok(result), | ||
| _ => Err(ServiceError::UnexpectedResponse), | ||
| } |
There was a problem hiding this comment.
Same issue as with skills_get above. A genuine server error is being turned into UnexpectedResponse instead of being passed through.
| let result = self | |
| .send_request(ClientRequest::ResourcesDirectoryReadRequest( | |
| ResourcesDirectoryReadRequest::new(params), | |
| )) | |
| .await; | |
| match result { | |
| Ok(ServerResult::ResourcesDirectoryReadResult(result)) => Ok(result), | |
| _ => Err(ServiceError::UnexpectedResponse), | |
| } | |
| let result = self | |
| .send_request(ClientRequest::ResourcesDirectoryReadRequest( | |
| ResourcesDirectoryReadRequest::new(params), | |
| )) | |
| .await?; | |
| match result { | |
| ServerResult::ResourcesDirectoryReadResult(result) => Ok(result), | |
| _ => Err(ServiceError::UnexpectedResponse), | |
| } |
There was a problem hiding this comment.
match only on the Ok variant, so real service errors are actually propagating
| ClientRequest::SkillsListRequest(_) => { | ||
| // Spec: the final `<skill-path>` segment of `uri` MUST equal | ||
| // `frontmatter.name`. Entries that violate it are invalid, so | ||
| // they are not served. | ||
| let skills: Vec<_> = self |
There was a problem hiding this comment.
SkillsGetRequest delegates to the inner service when the router has no answer, but SkillsListRequest is intercepted unconditionally — a wrapped service's own skills_list implementation becomes unreachable. Should an empty skill_router delegate instead?
| ClientRequest::SkillsListRequest(_) => { | |
| // Spec: the final `<skill-path>` segment of `uri` MUST equal | |
| // `frontmatter.name`. Entries that violate it are invalid, so | |
| // they are not served. | |
| let skills: Vec<_> = self | |
| ClientRequest::SkillsListRequest(request) => { | |
| if self.skill_router.map.is_empty() { | |
| return self | |
| .service | |
| .handle_request(ClientRequest::SkillsListRequest(request), context) | |
| .await; | |
| } | |
| // Spec: the final `<skill-path>` segment of `uri` MUST equal | |
| // `frontmatter.name`. Entries that violate it are invalid, so | |
| // they are not served. | |
| let skills: Vec<_> = self |
|
|
||
| let mut capability_calls = Vec::new(); | ||
| if has_skills { | ||
| capability_calls.push(quote! { .enable_skills_extension_without_directory_read() }); |
There was a problem hiding this comment.
The macro generates a working resources_directory_read, but advertises the extension without directoryRead.
| capability_calls.push(quote! { .enable_skills_extension_without_directory_read() }); | |
| capability_calls.push(quote! { .enable_skills_extension() }); |
| /// Whether this skill generates its content dynamically (no fixed file list). | ||
| pub dynamic: bool, |
There was a problem hiding this comment.
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.
| /// Whether this skill generates its content dynamically (no fixed file list). | |
| pub dynamic: bool, |
There was a problem hiding this comment.
+1
dropping the dead param because it doesn't apply here
| /// 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. |
There was a problem hiding this comment.
This doc tells readers to use skills_list for pagination, but this is skills_list. The helper that handles pagination is list_all_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. | |
| /// Send one `skills/list` request and return a single page of results. | |
| /// Use [`Peer::list_all_skills`] to follow `nextCursor` across pages. |
| /// "resources": [ | ||
| /// { "uri": "skill://doc-workflow/SKILL.md", "digest": "sha256:...", "size": 1234 } | ||
| /// ], | ||
| /// "_meta": { "ttlMs": 600000, "cacheScope": "metatarsal" } |
There was a problem hiding this comment.
"metatarsal" is not a cache scope (the valid values are "public"/"private").
| /// "_meta": { "ttlMs": 600000, "cacheScope": "metatarsal" } | |
| /// "_meta": { "ttlMs": 600000, "cacheScope": "public" } |
| //! 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." |
There was a problem hiding this comment.
Just for simplicity
| //! 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." | |
| //! Skill handler support — analogous to `tool.rs` for tools. |
| if name == SKILL_FILE { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
The comment above quotes the spec, which says the enclosing skill's resources list them too. But this skips all nested SKILL.md files, so their manifests aren't included in the enclosing skill's resources.
| if name == SKILL_FILE { | |
| continue; | |
| } |
|
Thanks for submitting, but a lot is missing from this. The feedback I sent earlier wasn't addressed + other things are wrong after more recent changes. I'm going to close this one out now. |
Sorry for the late response! I seperated each of your bullets into individual commits (force pushed away from ff211dd), however Dale's review is new, is there any area in my follow up commits at 60fc35a (head) that I might've missed from your feedback? |
Motivation and Context
SEP-2640 (Skills Extension for MCP) was merged Sep 13. This PR adds the Rust SDK
reference implementation:
skills/list,skills/get, andresources/directory/readmethods, plus the
#[skill]macro trio that lets server authors declare skillendpoints the same way they declare tool endpoints.
How Has This Been Tested?
cargo check --all-featurespasses clean. 26 conformance tests pass incrates/rmcp/tests/test_skills_conformance.rs(including 3 new RED->GREEN testsfor spec conformance gaps found in review). 10 macro tests pass.
FileSystemSkillServerdemo server runs end-to-end with fixtures underfixtures/skills-dir/. Full suite: 940 passed, 0 failed.Breaking Changes
None. All new methods are additive. Existing code is unaffected.
Types of changes
Checklist
Review Fixes (this push)
Three spec conformance gaps found and fixed during review:
Macro
skills_getgate — replaced 12-linedirectory_form || skill_md_formcheck with 2-line equality gate (
request.uri != route.attr.uri) on both macroand 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.
SkillsGetResultcache hints — addedttl_ms/cache_scopefields andincluded
SkillsGetResultinfill_missing_cache_hintsmatch. Spec:419 requiresthese on every
skills/getresponse.#[skill]macro compile-time URI validation — added/SKILL.mdsuffix checkso non-SKILL.md URIs fail at compile time, not at runtime.