Skip to content

Add the Skills extension (SEP-2640) - #635

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:add_the_skills_extension
Open

koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:add_the_skills_extension

Conversation

@koic

@koic koic commented Oct 27, 2026

Copy link
Copy Markdown
Member

Motivation and Context

Issue #568 asks for the Skills extension (SEP-2640, Final): a server publishes Agent Skills, folders holding a SKILL.md with YAML frontmatter plus supporting files, through two methods, skills/list and skills/get, whose entries repeat the frontmatter and list every file with its SHA-256 digest and size, and a host reads the files through resources/read and verifies each one against the entry before loading it.

Server side, MCP::Server.new(skills:) takes the catalog and advertises io.modelcontextprotocol/skills, with resources required alongside it as the SEP demands. MCP::Skill.from_directory covers the common case: every directory under a root that holds a SKILL.md becomes a skill, the frontmatter is read, digests and sizes are computed, a nested skill is published on its own and listed by the enclosing skill as supporting files (its manifest must be complete), and the default resources/read serves the files, answering -32602 for an unserved URI under a skill. resources/list carries each skill's SKILL.md with the frontmatter name and description, and skills/list and skills/get carry the cache hints, both as the specification asks. MCP::Skill.new builds a skill in code and checks at definition time what a host may reject (URI grammar, name equal to the last path segment, entries under the root with the skill's own SKILL.md among them, the SEP limits). A skill defined locally is also held to the Agent Skills rules the specification requires of a served skill and the Python SDK enforces: a name of 1 to 64 lowercase letters, digits, and single hyphens, and a description of at most 1024 characters. An entry a client receives is not, as Agent Skills advises clients. Server#skills_handlers(list:, get:) serves catalogs that depend on the caller, the shape the Python SDK's Skills(list_skills=, get_skill=) converged on and the one a Rails user asked for on #568; both handlers are required because a declaring server must answer skills/get for every skill it serves. Handlers do not combine with a registry that holds skills: a registered skill, its SKILL.md entry in resources/list, and its files are served to every caller, so skills_handlers refuses such a server, and a catalog that hides a skill from some callers serves the files under the same rule, with MCP::Skill.read_file. The files of a nested skill belong to the enclosing skill as well, so such a catalog hides a nested skill only by withholding both, which the documentation spells out. What the handlers answer is never stamped cacheScope: "public", but "private" where the server-wide cache_scope would make it so: a handler answers per caller and, returning skills, has no result of its own to carry a scope in.

Client side, MCP::Client#list_skills, #get_skill, and #read_skill_resource are admitted only after a handshake of the current connection advertised the extension and resources, as the SEP asks of clients and as the Python and TypeScript clients do. read_skill_resource verifies size, digest, and (for a SKILL.md) the frontmatter field by field, withholds content that does not match, and latches the skill so no further read of it is sent on this connection; that is what keeps a host from loading part of a tampered skill and what the sep-2640-client-verify-* conformance scenarios check. The SEP and the Python SDK name their thin, unverified wrapper read_skill_uri; the verified read gets its own name so the two never mean different things, and MCP::Skill.verify_resource and verify_frontmatter give hosts that read raw the same checks.

Frontmatter is read under rules stricter than YAML so every library reads it the same way: one document, no tags, aliases, or merge keys, String keys, JSON-representable values, and no plain scalar whose YAML 1.1 reading (Psych's) differs from its YAML 1.2 reading (the JavaScript and Go libraries'). Files of directory-backed skills are looked up by exact URI in a map built at construction, opened without following a symbolic link in the last path component (hard links are refused), and refused when their inode, size, link count, or digest changed since registration. The advertised capabilities and the dispatch gate are computed from the configured state, so a server never advertises what it does not serve. resources/directory/read, which the specification leaves optional, is not served in this release; declaring directoryRead is refused so it can be added later without a change in meaning. skills/list and skills/get join the protocol request methods that are not run without an id.

Closes #568.

How Has This Been Tested?

test/mcp/skill_test.rb, test/mcp/skill/resource_entry_test.rb, test/mcp/skill/frontmatter_test.rb, test/mcp/skill/directory_test.rb, test/mcp/server_skills_test.rb, test/mcp/client_skills_test.rb, test/mcp/methods_test.rb, and the sep-2640-client-* drivers in conformance/client.rb; the pending sep-2640-skills-enumeration and -manifest server scenarios pass when run by name against conformance/server.rb, and -directory skips because directoryRead is not declared. Against the library at cc88656, skills/list answers -32601 and MCP::Client#read_resource hands back a tampered SKILL.md without complaint. The server test also checks that neither skills method runs without a request id, however the skills were configured.

Breaking Changes

None for servers that neither configure skills nor declare the extension by hand: Server#capabilities= now also accepts an MCP::Server::Capabilities builder and raises ArgumentError when the Hash declares the Skills extension without skills being configured, and Server#define_resource and #resources= refuse a URI that a directory-backed skill serves. MCP::Client#connect keeps its signature and return value.

Motivation and Context

How Has This Been Tested?

Breaking Changes

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 cause existing functionality to change)
  • 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
  • I have added or updated documentation as needed

@koic
koic force-pushed the add_the_skills_extension branch 4 times, most recently from 58b995f to 88f84b1 Compare October 27, 2026 04:45
## Motivation and Context

Issue modelcontextprotocol#568 asks for the Skills extension (SEP-2640, Final): a server publishes Agent Skills, folders holding
a `SKILL.md` with YAML frontmatter plus supporting files, through two methods, `skills/list` and `skills/get`,
whose entries repeat the frontmatter and list every file with its SHA-256 digest and size, and a host reads
the files through `resources/read` and verifies each one against the entry before loading it.

Server side, `MCP::Server.new(skills:)` takes the catalog and advertises `io.modelcontextprotocol/skills`, with
`resources` required alongside it as the SEP demands. `MCP::Skill.from_directory` covers the common case: every
directory under a root that holds a `SKILL.md` becomes a skill, the frontmatter is read, digests and sizes are
computed, a nested skill is published on its own and listed by the enclosing skill as supporting files (its
manifest must be complete), and the default `resources/read` serves the files, answering `-32602` for an unserved
URI under a skill. `resources/list` carries each skill's `SKILL.md` with the frontmatter `name` and `description`,
and `skills/list` and `skills/get` carry the cache hints, both as the specification asks. `MCP::Skill.new` builds
a skill in code and checks at definition time what a host may reject (URI grammar, `name` equal to the last path
segment, entries under the root with the skill's own `SKILL.md` among them, the SEP limits).
A skill defined locally is also held to the Agent Skills rules the specification requires of a served skill
and the Python SDK enforces: a `name` of 1 to 64 lowercase letters, digits, and single hyphens, and
a `description` of at most 1024 characters. An entry a client receives is not, as Agent Skills advises clients.
`Server#skills_handlers(list:, get:)` serves catalogs that depend on the caller, the shape the Python SDK's
`Skills(list_skills=, get_skill=)` converged on and the one a Rails user asked for on modelcontextprotocol#568; both handlers are
required because a declaring server must answer `skills/get` for every skill it serves.
Handlers do not combine with a registry that holds skills: a registered skill, its `SKILL.md` entry in
`resources/list`, and its files are served to every caller, so `skills_handlers` refuses such a server, and
a catalog that hides a skill from some callers serves the files under the same rule, with `MCP::Skill.read_file`.
The files of a nested skill belong to the enclosing skill as well, so such a catalog hides a nested skill only by
withholding both, which the documentation spells out.
What the handlers answer is never stamped `cacheScope: "public"`, but `"private"` where the server-wide `cache_scope`
would make it so: a handler answers per caller and, returning skills, has no result of its own to carry a scope in.

Client side, `MCP::Client#list_skills`, `#get_skill`, and `#read_skill_resource` are admitted only after
a handshake of the current connection advertised the extension and `resources`, as the SEP asks of clients and
as the Python and TypeScript clients do. `read_skill_resource` verifies size, digest, and (for a `SKILL.md`)
the frontmatter field by field, withholds content that does not match, and latches the skill so no further
read of it is sent on this connection; that is what keeps a host from loading part of a tampered skill and
what the `sep-2640-client-verify-*` conformance scenarios check. The SEP and the Python SDK name their thin,
unverified wrapper `read_skill_uri`; the verified read gets its own name so the two never mean different
things, and `MCP::Skill.verify_resource` and `verify_frontmatter` give hosts that read raw the same checks.

Frontmatter is read under rules stricter than YAML so every library reads it the same way: one document,
no tags, aliases, or merge keys, String keys, JSON-representable values, and no plain scalar whose YAML 1.1
reading (Psych's) differs from its YAML 1.2 reading (the JavaScript and Go libraries'). Files of directory-backed
skills are looked up by exact URI in a map built at construction, opened without following a symbolic link in
the last path component (hard links are refused), and refused when their inode, size, link count, or digest
changed since registration. The advertised capabilities and the dispatch gate are computed from the configured
state, so a server never advertises what it does not serve.
`resources/directory/read`, which the specification leaves optional, is not served in this release; declaring
`directoryRead` is refused so it can be added later without a change in meaning.
`skills/list` and `skills/get` join the protocol request methods that are not run without an id.

The entry's `_meta`, which the stable text declares on `Skill` and the Python and TypeScript entries carry,
is passed through on both sides: `MCP::Skill.new(meta:)` puts it on the `skills/list` and `skills/get` entries,
and a client entry exposes what the server sent as `#meta`, under the JSON rules of the frontmatter and with
no key acted on, so an annotation a catalog attaches reaches the host whichever SDK is on the other side.

Strings JSON cannot encode (bytes invalid in their labeled encoding, or a binary String with a byte beyond
ASCII, which `valid_encoding?` alone lets through) are refused in the frontmatter and `_meta` at definition
time as well: json 3.1 cannot generate the response carrying them, while older json still generates a binary
String holding valid UTF-8 bytes, recent versions with a deprecation warning announcing that change. Frontmatter
read from a file was already held to valid UTF-8.

Closes modelcontextprotocol#568.

## How Has This Been Tested?

test/mcp/skill_test.rb, test/mcp/skill/resource_entry_test.rb, test/mcp/skill/frontmatter_test.rb,
test/mcp/skill/directory_test.rb, test/mcp/server_skills_test.rb, test/mcp/client_skills_test.rb,
test/mcp/methods_test.rb, and the `sep-2640-client-*` drivers in conformance/client.rb; the pending
`sep-2640-skills-enumeration` and `-manifest` server scenarios pass when run by name against conformance/server.rb,
and `-directory` skips because `directoryRead` is not declared. Against the library at cc88656,
`skills/list` answers `-32601` and `MCP::Client#read_resource` hands back a tampered `SKILL.md` without complaint.
The server test also checks that neither skills method runs without a request id, however the skills were
configured.
The `_meta` of an entry is checked in the skill, server, and client tests, on the wire and as `#meta`.
skill_test.rb requires digest itself: alone, it computed digests before anything autoloaded `MCP::Skill`
and failed under most seeds.
Without the encoding check, `MCP::Skill.new` accepted a binary string with a byte beyond ASCII and
`JSON.generate` then failed on `to_h`.

## Breaking Changes

None for servers that neither configure skills nor declare the extension by hand: `Server#capabilities=` now
also accepts an `MCP::Server::Capabilities` builder and raises `ArgumentError` when the Hash declares the Skills
extension without skills being configured, and `Server#define_resource` and `#resources=` refuse a URI that
a directory-backed skill serves. `MCP::Client#connect` keeps its signature and return value.

The server test helper takes the request params positionally only, since Ruby 2.7 reads a trailing Hash as keywords
when the method also takes one.
@koic
koic force-pushed the add_the_skills_extension branch from 88f84b1 to e3d8c63 Compare October 27, 2026 04:49

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SEP-2640: Skills Extension

1 participant