Skip to content

Commit 1126766

Browse files
authored
Merge pull request #635 from koic/add_the_skills_extension
Add the Skills extension (SEP-2640)
2 parents c287c96 + e3d8c63 commit 1126766

25 files changed

Lines changed: 4880 additions & 16 deletions

‎conformance/client.rb‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,21 @@ def build_provider_for(scenario, context)
297297
abort("No tool exposed by conformance server for #{scenario}") unless tool
298298
client.call_tool(tool: tool, arguments: {})
299299
end
300+
when "sep-2640-client-no-prefetch"
301+
# SEP-2640: assembling a registry reads only the listing; the check is that no skill file is fetched.
302+
client.list_skills
303+
when "sep-2640-client-verify-digest", "sep-2640-client-verify-size", "sep-2640-client-verify-frontmatter"
304+
# SEP-2640: the server tampers with `SKILL.md` on the wire. The verified read raises and latches the skill,
305+
# so the supporting file below is never requested, which is how the harness detects the rejection.
306+
client.list_skills
307+
client.read_skill_resource(uri: "skill://pdf-processing/SKILL.md")
308+
client.read_skill_resource(uri: "skill://pdf-processing/references/FORMS.md")
300309
else
301310
abort("Unknown or unsupported scenario: #{scenario}")
302311
end
312+
rescue MCP::Client::SkillVerificationError => e
313+
warn("#{scenario}: client rejected the skill content as expected (#{e.reason})")
314+
exit
303315
rescue MCP::Client::OAuth::Flow::AuthorizationError, MCP::Client::RequestHandlerError, Faraday::Error => e
304316
warn("#{scenario}: client ended in the expected terminal failure (#{e.class}: #{e.message})")
305317
exit

‎conformance/server.rb‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,7 @@ def build_server
947947
],
948948
resources: resources,
949949
resource_templates: resource_templates,
950+
skills: skills,
950951
# A per-boot random key is enough for conformance: every MRTR round trip completes
951952
# within one server process, and sealing keeps the fixture's `requestState` values
952953
# tamper-evident without any tool-level verification code.
@@ -995,6 +996,12 @@ def resource_templates
995996
]
996997
end
997998

999+
# SEP-2640: the skill directories under conformance/skills, so the `sep-2640-skills-*` server scenarios have
1000+
# a catalog to enumerate; the files are served through `MCP::Skill.read_file` in the read handler below.
1001+
def skills
1002+
MCP::Skill.from_directory(File.expand_path("skills", __dir__))
1003+
end
1004+
9981005
def configure_handlers(server)
9991006
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug")
10001007
server.server_context = server
@@ -1008,6 +1015,10 @@ def configure_resources_read_handler(server)
10081015
server.resources_read_handler do |params|
10091016
uri = params[:uri].to_s
10101017

1018+
# A custom reader replaces the default one, skill files included (SEP-2640).
1019+
skill_file = MCP::Skill.read_file(server.skills, uri)
1020+
next skill_file if skill_file
1021+
10111022
case uri
10121023
when "test://static-text"
10131024
[
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
name: pdf-processing
3+
description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
4+
---
5+
6+
# PDF processing
7+
8+
Use the forms reference in `references/FORMS.md` when a task involves filling a PDF form.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Forms
2+
3+
Fill AcroForm fields by name; flatten the document before delivery.

‎docs/_client/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ This class supports:
2424
- Resource listing via the `resources/list` method (`MCP::Client#resources`)
2525
- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`)
2626
- Resource reading via the `resources/read` method (`MCP::Client#read_resource`)
27+
- Skills (SEP-2640) via the `skills/list` and `skills/get` methods and digest-verified reads (`MCP::Client#list_skills`, `#get_skill`, `#read_skill_resource`), see [Skills](/extensions/skills/)
2728
- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`)
2829
- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`)
2930
- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completion](/server/completion/)

‎docs/_extensions/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ as described on [Capability Extensions](/extensions/capability-extensions/).
1515
The extensions this SDK ships support for:
1616

1717
- [MCP Apps](/extensions/mcp-apps/) (SEP-1865) - interactive HTML user interfaces rendered by the host for tool results
18+
- [Skills](/extensions/skills/) (SEP-2640) - Agent Skills served over MCP resources, with digest-verified reads on the client

‎docs/_extensions/skills.md‎

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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.

‎docs/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ For comprehensive documentation, see:
112112
- [Protocol Versions](/protocol-versions/) - supported versions, the era model, and client negotiation
113113
- [Building Servers](server/) - transports, discovery, tools, prompts, resources, server-to-client requests, multi round-trip requests, notifications, protocol utilities, configuration, and OAuth 2.1 authorization
114114
- [Building Clients](client/) - transports, lifecycle negotiation, multi round-trip requests, and OAuth 2.1 authorization
115-
- [Extensions](/extensions/) - capability extensions and MCP Apps
115+
- [Extensions](/extensions/) - capability extensions, MCP Apps, and Skills
116116

117117
## API Documentation
118118

‎lib/mcp.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module MCP
2424
autoload :ResultType, "mcp/result_type"
2525
autoload :Server, "mcp/server"
2626
autoload :ServerSession, "mcp/server_session"
27+
autoload :Skill, "mcp/skill"
2728
autoload :Tool, "mcp/tool"
2829
autoload :TraceContext, "mcp/trace_context"
2930

0 commit comments

Comments
 (0)