Skip to content

feat(rmcp): implement SEP-2640 MCP Skills Extension - #1286

Closed
branben wants to merge 27 commits into
modelcontextprotocol:mainfrom
branben:feat/sep-2640-skills
Closed

branben wants to merge 27 commits into
modelcontextprotocol:mainfrom
branben:feat/sep-2640-skills

Conversation

@branben

@branben branben commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

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, and resources/directory/read
methods, plus the #[skill] macro trio that lets server authors declare skill
endpoints the same way they declare tool endpoints.

How Has This Been Tested?

cargo check --all-features passes clean. 26 conformance tests pass in
crates/rmcp/tests/test_skills_conformance.rs (including 3 new RED->GREEN tests
for spec conformance gaps found in review). 10 macro tests pass.
FileSystemSkillServer demo server runs end-to-end with fixtures under
fixtures/skills-dir/. Full suite: 940 passed, 0 failed.

Breaking Changes

None. All new methods are additive. Existing code is unaffected.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would change existing functionality)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling (catch-all match arm for future SEP methods)
  • I have added or updated documentation as needed

Review Fixes (this push)

Three spec conformance gaps found and fixed during review:

  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.

Brandon Bennett added 2 commits September 15, 2026 10:29
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.
@branben
branben requested a review from a team as a code owner September 18, 2026 23:50
@github-actions github-actions Bot added T-documentation Documentation improvements T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-core Core library changes T-handler Handler implementation changes T-macros Macro changes T-model Model/data structure changes T-service Service layer changes labels Sep 18, 2026
@branben
branben force-pushed the feat/sep-2640-skills branch from aba85c0 to 15ed73a Compare September 19, 2026 02:19
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.
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 alexhancock left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 the resources capability, so compliant clients won't call any skill methods.
  • resources treated as optional: entries can omit it, and the demo leaves out SKILL.md and subdirectory files. The spec requires a complete list.
  • Wrong directory-read wire format: it returns children with isDirectory instead of resources with mimeType: "inode/directory".
  • Directory read misbehaves: Router always returns an empty list, the demo lists the parent folder, and missing paths don't return -32602.
  • skills/get too loose: it accepts any file URI inside a skill, returns -32601 instead of -32602 when nothing matches, and the macro version drops the frontmatter and resources.
  • URI parsing bug: parse_skill_uri gets 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.

Comment thread crates/rmcp-macros/src/lib.rs Outdated
@@ -1,4 +1,5 @@
#![doc = include_str!("../README.md")]
#![allow(dead_code)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we adding this?

@branben branben Oct 11, 2026 •

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.

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

Comment thread crates/rmcp/src/handler/client.rs Outdated
)
.await
.map(ClientResult::CustomResult),
ServerRequest::SkillsListRequest(request) => self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the wrong direction no? ServerRequest holds requests the server makes to the client.

@branben branben Oct 11, 2026 •

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.

I misunderstood this and mentally had it the opposite! noted, and adjusted in my upcoming commit

client logic ended up in the wrong union

Brandon Bennett added 6 commits October 10, 2026 05:12
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
@branben
branben force-pushed the feat/sep-2640-skills branch from 884623b to 301eac9 Compare October 11, 2026 07:33
Brandon Bennett added 5 commits October 11, 2026 05:49
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.
@branben
branben force-pushed the feat/sep-2640-skills branch from 745c669 to 73408a2 Compare October 11, 2026 22:53
@branben

branben commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor Author

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 the resources capability, so compliant clients won't call any skill methods.
  • resources treated as optional: entries can omit it, and the demo leaves out SKILL.md and subdirectory files. The spec requires a complete list.
  • Wrong directory-read wire format: it returns children with isDirectory instead of resources with mimeType: "inode/directory".
  • Directory read misbehaves: Router always returns an empty list, the demo lists the parent folder, and missing paths don't return -32602.
  • skills/get too loose: it accepts any file URI inside a skill, returns -32601 instead of -32602 when nothing matches, and the macro version drops the frontmatter and resources.
  • URI parsing bug: parse_skill_uri gets 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.

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.

Brandon Bennett added 3 commits October 12, 2026 15:05
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.
@branben

branben commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

follow up testing revealed some more defects that I've addressed in these commits:

  • the Router's resources/directory/read returned a hardcoded empty list instead of delegating to the skill route's declared files

  • directory-form URIs (skill://alpha) were unroutable, fill_missing_cache_hints skipped the two SEP-2640 skill result types

  • the FS demo server never advertised the skills extension.

[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

Brandon Bennett added 8 commits October 13, 2026 07:42
…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
Comment on lines +221 to +246
// 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);

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.

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)?

Suggested 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);

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.

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!

Comment on lines +1762 to +1770
let result = self
.send_request(ClientRequest::SkillsGetRequest(SkillsGetRequest::new(
params,
)))
.await;
match result {
Ok(ServerResult::SkillsGetResult(result)) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}

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.

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.

Suggested change
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),
}

Comment on lines +1780 to +1788
let result = self
.send_request(ClientRequest::ResourcesDirectoryReadRequest(
ResourcesDirectoryReadRequest::new(params),
))
.await;
match result {
Ok(ServerResult::ResourcesDirectoryReadResult(result)) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}

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.

Same issue as with skills_get above. A genuine server error is being turned into UnexpectedResponse instead of being passed through.

Suggested change
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),
}

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.

match only on the Ok variant, so real service errors are actually propagating

Comment on lines +161 to +165
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

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.

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?

Suggested change
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() });

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.

The macro generates a working resources_directory_read, but advertises the extension without directoryRead.

Suggested change
capability_calls.push(quote! { .enable_skills_extension_without_directory_read() });
capability_calls.push(quote! { .enable_skills_extension() });

Comment on lines +21 to +22
/// Whether this skill generates its content dynamically (no fixed file list).
pub dynamic: bool,

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

Comment on lines +1698 to +1700
/// 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.

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.

This doc tells readers to use skills_list for pagination, but this is skills_list. The helper that handles pagination is list_all_skills.

Suggested change
/// 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" }

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.

"metatarsal" is not a cache scope (the valid values are "public"/"private").

Suggested change
/// "_meta": { "ttlMs": 600000, "cacheScope": "metatarsal" }
/// "_meta": { "ttlMs": 600000, "cacheScope": "public" }

Comment on lines +1 to +6
//! 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."

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.

Just for simplicity

Suggested change
//! 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.

Comment on lines +100 to +102
if name == SKILL_FILE {
continue;
}

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.

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.

Suggested change
if name == SKILL_FILE {
continue;
}

@alexhancock

Copy link
Copy Markdown
Contributor

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.

@branben

branben commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-config Configuration file changes T-core Core library changes T-dependencies Dependencies related changes T-documentation Documentation improvements T-handler Handler implementation changes T-macros Macro changes T-model Model/data structure changes T-service Service layer changes T-test Testing related changes

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants