|
| 1 | +--- |
| 2 | +layout: default |
| 3 | +title: Skills |
| 4 | +nav_order: 4 |
| 5 | +--- |
| 6 | + |
| 7 | +# Skills |
| 8 | + |
| 9 | +Skills (SEP-2640) is a Final extension (negotiated via [Capability Extensions](/extensions/capability-extensions/)) that lets a server |
| 10 | +publish Agent Skills: folders holding a `SKILL.md` with YAML frontmatter plus supporting files, served as ordinary MCP resources |
| 11 | +under `skill://` URIs. The extension adds two methods, `skills/list` and `skills/get`, whose entries name a skill's `SKILL.md`, |
| 12 | +repeat its frontmatter, and list its files with SHA-256 digests and sizes, so a host can approve a skill from the listing and |
| 13 | +verify every file it later fetches. See the [Skills specification](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx), |
| 14 | +the stable text behind SEP-2640. |
| 15 | + |
| 16 | +## Serving Skills |
| 17 | + |
| 18 | +The common case is a directory of skill folders. `MCP::Skill.from_directory` reads the frontmatter, computes the digests and |
| 19 | +sizes, and the server serves `skills/list`, `skills/get`, and `resources/read` for every file: |
| 20 | + |
| 21 | +```ruby |
| 22 | +# skills/ |
| 23 | +# pdf-processing/ |
| 24 | +# SKILL.md <- frontmatter `name` must equal the directory name |
| 25 | +# references/FORMS.md |
| 26 | +server = MCP::Server.new(name: "catalog", skills: MCP::Skill.from_directory("skills")) |
| 27 | +``` |
| 28 | + |
| 29 | +Passing `skills:` advertises `io.modelcontextprotocol/skills` under `capabilities.extensions`; `resources`, which the SEP requires |
| 30 | +alongside it, is part of the default capabilities. An empty Array serves an empty catalog; `nil` (the default) serves no skills, |
| 31 | +and `skills/list` then answers `-32601`. `MCP::Skill.capability` exists for a hand-built `MCP::Server::Capabilities`; such |
| 32 | +a declaration must be paired with a non-`nil` `skills:` in the same `MCP::Server.new` call. |
| 33 | + |
| 34 | +Skills assembled in code are `MCP::Skill` values whose files the application serves itself: |
| 35 | + |
| 36 | +```ruby |
| 37 | +skill_md = File.read("git-workflow/SKILL.md") |
| 38 | +skill = MCP::Skill.new( |
| 39 | + uri: "skill://git-workflow/SKILL.md", |
| 40 | + frontmatter: { name: "git-workflow", description: "Branching and commit conventions. Use when preparing a commit or a pull request." }, |
| 41 | + resources: [MCP::Skill::ResourceEntry.from_content(uri: "skill://git-workflow/SKILL.md", content: skill_md)], |
| 42 | +) |
| 43 | +server = MCP::Server.new(name: "catalog", skills: [skill]) |
| 44 | +server.define_resource( |
| 45 | + uri: "skill://git-workflow/SKILL.md", |
| 46 | + name: "git-workflow", # a SKILL.md resource takes its name and description from the frontmatter |
| 47 | + description: "Branching and commit conventions. Use when preparing a commit or a pull request.", |
| 48 | + mime_type: "text/markdown", |
| 49 | +) { skill_md } |
| 50 | +``` |
| 51 | + |
| 52 | +Every URI a skill lists must be served, or a client reading it fails closed. `MCP::Skill.new` checks what a host may reject: |
| 53 | +the URI shape (`skill://<path>/SKILL.md`, segments limited to letters, digits, `-`, `.`, `_`, and `~`), `name` equal to the last |
| 54 | +path segment and following the Agent Skills naming rules (1 to 64 lowercase letters, digits, and single hyphens between them), |
| 55 | +a `description` of 1 to 1024 characters, JSON-representable frontmatter values, entries under the skill root with |
| 56 | +the skill's own `SKILL.md` among them, and the SEP limits of 512 entries and 16 MiB. `resources: "dynamic"` declares a skill |
| 57 | +whose files are generated per request and carry no digests. `meta:` is the entry's `_meta`, |
| 58 | +passed through under the same JSON rules as the frontmatter and given no meaning by the SDK; |
| 59 | +the `io.modelcontextprotocol.skills/` key prefix is reserved for the extension, |
| 60 | +so a server's own annotations use its own reverse-domain prefix. |
| 61 | +A skill nested in another skill's directory is a skill of its own |
| 62 | +and, as the specification requires of a complete manifest, its files (its `SKILL.md` included) are supporting files of |
| 63 | +the enclosing skill as well: `from_directory` lists them in both entries, and a hand-built enclosing skill lists them itself. |
| 64 | + |
| 65 | +With the default `resources/list` handler, the listing carries the `SKILL.md` of every registered skill with `name` and |
| 66 | +`description` taken from the frontmatter and `mimeType` `text/markdown`, as the specification asks of a `SKILL.md` resource, |
| 67 | +unless the application registered a resource at that URI itself (as the example above does in order to serve the file). |
| 68 | + |
| 69 | +{: .important } |
| 70 | +> Files of a directory-backed skill are looked up by exact URI in a map built at construction, so request data never reaches |
| 71 | +> the filesystem. Dotfiles, symbolic links, FIFOs, and devices are skipped, a hard link is refused, and every other regular file |
| 72 | +> is published, editor backups included. The tree is trusted as the operator's: keep it writable only by the operator while |
| 73 | +> skills are built and served. Each file is read at request time and refused with `-32603` when its inode, size, or digest |
| 74 | +> no longer matches what was registered, or when it has gained a hard link. A read of a URI under such a skill that names |
| 75 | +> none of its files answers `-32602`, as the specification requires, so resource templates are shadowed for the whole subtree |
| 76 | +> under the skill root; URIs outside every skill root keep the server's usual behavior. A resource registered with the URI of |
| 77 | +> a skill file is refused. `resources_read_handler` |
| 78 | +> replaces the default reader, skill files included; delegate with `MCP::Skill.read_file(server.skills, params[:uri]) || |
| 79 | +> your_contents`, which returns the `contents` Array or `nil`, and answer `-32602` yourself for a `nil` under a skill root. |
| 80 | +
|
| 81 | +### Catalogs That Depend on the Caller |
| 82 | + |
| 83 | +A catalog that depends on the caller is served from handlers; both are required because a server declaring the extension |
| 84 | +must answer `skills/get` for every skill it serves, listed or not: |
| 85 | + |
| 86 | +```ruby |
| 87 | +server.skills_handlers( |
| 88 | + # Array<MCP::Skill>; the server paginates |
| 89 | + list: ->(params, server_context:) { skills_for(server_context) }, |
| 90 | + # MCP::Skill, or nil for -32602 |
| 91 | + get: ->(params, server_context:) { skills_for(server_context).find { |skill| skill.uri == params[:uri] } }, |
| 92 | +) |
| 93 | +server.resources_read_handler do |params, server_context:| |
| 94 | + MCP::Skill.read_file(skills_for(server_context), params[:uri]) || other_contents(params[:uri]) |
| 95 | +end |
| 96 | +``` |
| 97 | + |
| 98 | +Handlers may receive `server_context:` when they declare the keyword. A result outside this contract |
| 99 | +(not an `MCP::Skill`, a duplicate URI, a skill for another URI) is answered with `-32603`. |
| 100 | +`skills/list` and `skills/get` carry the `ttlMs`/`cacheScope` cache hints the way `resources/list` and `resources/read` do. |
| 101 | + |
| 102 | +{: .important } |
| 103 | +> A server serves skills from the `skills:` registry or from handlers, never both: `skills_handlers` raises `ArgumentError` |
| 104 | +> while the registry holds skills, because a registered skill, its `SKILL.md` entry in `resources/list`, and its files |
| 105 | +> are served to every caller. |
| 106 | +
|
| 107 | +A catalog that hides a skill from some callers applies its rule everywhere itself: |
| 108 | + |
| 109 | +- `server_context` is the value the server was built with, plus the `_meta` of the request under `:_meta`, |
| 110 | + so the catalog needs a server built for each caller from the authenticated request |
| 111 | + (see [Server Context](/server/server-context/)). |
| 112 | +- Do not reassign the `server_context` of a server that concurrent requests share, |
| 113 | + and do not decide from `_meta`, which the client chooses. |
| 114 | +- Filter `get` as well as `list`, and the reads, as the `resources_read_handler` above does. |
| 115 | + `MCP::Skill.read_file` serves the files of the directory-backed skills among those it is given |
| 116 | + and returns `nil` for any other URI. |
| 117 | +- A handler that answers `-32602` for an unserved URI under a skill root, as the specification asks, |
| 118 | + judges that against the caller's skills too, so that a hidden skill answers exactly like a URI that does not exist. |
| 119 | +- What the handlers answer is never marked `cacheScope: "public"`: |
| 120 | + where the `cache_scope` of the server would make it so, the result says `"private"`. |
| 121 | +- The reads are not covered: on a server configured with `cache_scope: "public"`, |
| 122 | + return `{ contents: contents, cacheScope: "private" }` from the `resources_read_handler` for files that depend on the caller. |
| 123 | +- Leaving a nested skill out of a caller's catalog does not hide it from a caller who is given the enclosing skill: |
| 124 | + the enclosing skill lists the nested files as its own, with their URIs, digests, and sizes, as a complete manifest must, |
| 125 | + and serves them. |
| 126 | +- Keep a skill that some callers must not see out of the directory of a skill they may see, |
| 127 | + or withhold the enclosing skill from them as well. |
| 128 | + |
| 129 | +## Reading Skills |
| 130 | + |
| 131 | +```ruby |
| 132 | +page = client.list_skills # ListSkillsResult(skills:, next_cursor:, meta:, ttl_ms:, cache_scope:) |
| 133 | +entry = client.get_skill(uri: "skill://pdf-processing/SKILL.md").skill |
| 134 | +entry.meta # the entry's _meta as a frozen Hash, or nil |
| 135 | +result = client.read_skill_resource(uri: entry.uri, skill: entry) |
| 136 | +result.bytes # the verified bytes, frozen |
| 137 | +client.read_skill_resource(uri: "skill://pdf-processing/references/FORMS.md", skill: entry).bytes |
| 138 | +client.skills_capability # the extension settings, or nil |
| 139 | +``` |
| 140 | + |
| 141 | +Nothing is read ahead of need: the specification forbids fetching a skill's files on connection, on listing, or at approval, |
| 142 | +so call `read_skill_resource` for `SKILL.md` when the skill is loaded and for a supporting file when it is used. |
| 143 | +A relative reference in a `SKILL.md` resolves against the directory of that `SKILL.md`, which for a nested skill is its own root |
| 144 | +and not the enclosing one; `read_skill_resource` takes the resulting URI. |
| 145 | + |
| 146 | +`read_skill_resource` fetches a file through `resources/read` and checks it against the entry remembered from `list_skills` or |
| 147 | +`get_skill` (or passed as `skill:`): exactly one content item for the URI, the size before anything is decoded, the SHA-256 |
| 148 | +digest, and for a `SKILL.md` the frontmatter compared field by field with the entry. A mismatch raises |
| 149 | +`MCP::Client::SkillVerificationError` (`reason` `:contents`, `:size`, `:digest`, `:parse`, or `:frontmatter`), withholds |
| 150 | +the content, and latches the skill: no further read of it is sent on this connection, even after a new listing, so reading |
| 151 | +a latched skill again takes a new connection. A supporting file is read only after the skill's `SKILL.md` verified; |
| 152 | +without `skill:` it resolves to the most specific remembered skill that contains it, never to an enclosing one, |
| 153 | +and a `SKILL.md` URI resolves only to its own entry, never as a supporting file of an enclosing skill. |
| 154 | +The specification resolves a read against the entry the host loaded the skill from, so a host acting on a skill |
| 155 | +keeps that entry at least until the skill's `SKILL.md` leaves the model's context and passes it as `skill:`; |
| 156 | +when an enclosing skill lists the file too and no `skill:` is given, the read is refused as `:ambiguous` rather than guessed. |
| 157 | +Calls refused before anything is sent raise `MCP::Client::SkillReadRefusedError` with `reason` `:not_advertised` |
| 158 | +(no handshake of this connection advertised the extension and `resources`), `:connection_changed`, `:latched`, |
| 159 | +`:unknown_skill` (pass `skill:` or call `get_skill` first; also the answer for a URI longer than 8 KiB or deeper than |
| 160 | +128 segments, which this SDK does not resolve), `:ambiguous`, `:dynamic`, `:not_in_manifest`, or `:skill_md_unverified`; |
| 161 | +later releases may add reasons, so treat an unknown one as a refusal. |
| 162 | +For a skill the host is acting on, `:not_in_manifest` is what the specification calls a read of an unlisted file, |
| 163 | +a verification failure equivalent to a digest mismatch: no content is fetched, the SDK latches nothing |
| 164 | +(the URI may come from model output), and a host that takes it as a sign that the skill changed fetches the entry again |
| 165 | +with `get_skill`. Both classes inherit from `MCP::Client::SkillError`, and their messages show URIs escaped to ASCII, |
| 166 | +since a server chooses them. `meta:` and `cancellation:` work as on the other methods. |
| 167 | + |
| 168 | +Entries are remembered per client and bound to the connection: `connect` forgets them, and a `connect` that performs no new |
| 169 | +handshake (the transport is still connected) keeps the latches. A listing or `get_skill` that replaces an entry drops its |
| 170 | +verified status, so supporting reads of that skill are refused until its `SKILL.md` is read again, and the specification asks |
| 171 | +the host to treat a changed `resources` set as revoking any approval bound to the old one. `skills_capability` returning `nil` |
| 172 | +means not ready or not advertised on the current connection, never that skill files may be read raw. |
| 173 | + |
| 174 | +Hosts that load a skill with `read_resource` instead own the obligations above: stop loading the skill on any raise and |
| 175 | +verify `SKILL.md` before any other file. `MCP::Skill.verify_resource(skill, uri, bytes)` checks size and digest (pass `text.b` |
| 176 | +for a text item, the decoded `blob` otherwise) and `MCP::Skill.verify_frontmatter(skill, text)` compares the frontmatter; a static |
| 177 | +`SKILL.md` needs both, and a `"dynamic"` skill can only be checked with the second. |
| 178 | + |
| 179 | +{: .important } |
| 180 | +> The frontmatter of a skill entry is untrusted input, and so is its `_meta`. The SDK compares the frontmatter and passes both |
| 181 | +> through; it never acts on a field. |
| 182 | +> The SEP requires hosts to ignore or approval-gate `allowed-tools`, hooks, and scripts in skills that arrive over MCP, and to treat |
| 183 | +> `name` and `description` as display text. `mime_type` on a read result is the server's unverified display metadata. These are |
| 184 | +> the obligations the SDK can state here; the Security Considerations of the specification list the rest a host must implement |
| 185 | +> itself: tagging content with its originating server, binding resource reads to that server, resolving skill names per origin, |
| 186 | +> binding persisted approval to the entry's `resources` set, and isolating any disk cache from filesystem-skill discovery. |
| 187 | +
|
| 188 | +{: .note } |
| 189 | +> Frontmatter is read under rules stricter than YAML so that every YAML library reads it the same way: one document without |
| 190 | +> directives or document markers, no tags, aliases, or merge keys, keys that are untagged strings, values JSON can carry, and no |
| 191 | +> plain scalar whose YAML 1.1 reading differs from its YAML 1.2 reading (`yes`, `no`, `on`, `off`, `010`, `1:20`, `1_000`, |
| 192 | +> `0o10`, and the like): quote such values. The NEL, LINE SEPARATOR, and PARAGRAPH SEPARATOR characters (line breaks in |
| 193 | +> YAML 1.1 only) and integers outside the range from -(2^53 - 1) to 2^53 - 1 (which JavaScript readers round) are refused |
| 194 | +> as well. A leading UTF-8 BOM is skipped for extraction while digests cover it. The block is limited to 64 KiB by this SDK, |
| 195 | +> not by the protocol. Hosts must accept skills up to the 512-entry and 16 MiB limits and may accept larger ones, so |
| 196 | +> the client accepts entries of any size; `MCP::Skill#total_size` and `#resources` are the hooks for a host that declines |
| 197 | +> large skills. |
| 198 | +
|
| 199 | +`resources/directory/read` and the `directoryRead` setting are not served in this release; declaring `directoryRead` is refused. |
0 commit comments