Repository navigation
feat: Skills extension for server and client (ext/skills subpaths) #2972
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/server-extensions
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| --- | ||
| '@modelcontextprotocol/core-internal': minor | ||
| '@modelcontextprotocol/client': minor | ||
| '@modelcontextprotocol/server': minor | ||
| --- | ||
|
|
||
| The MCP Skills extension (`io.modelcontextprotocol/skills`, SEP-2640) as a pair of extensions. | ||
|
|
||
| `@modelcontextprotocol/server/ext/skills`: `new SkillsExtension(source)` in `ServerOptions.extensions` declares the extension and the `resources` capability, and serves `skills/list` and `skills/get` from a `SkillSource`, plus `resources/directory/read` (declared as `directoryRead`) when the source implements `readDirectory`. Entries are checked against the specification's structural rules before they are sent. Skill files stay ordinary resources; `skillResourceOf` computes the digest and size an entry lists for each. | ||
|
|
||
| `@modelcontextprotocol/client/ext/skills`: `new SkillsClientExtension()` in `ClientOptions.extensions` wraps `list`, `get` and `readDirectory`, each refused unless the server declared support, and `read(skill, uri)`, which fetches a skill file and verifies it is listed and matches the entry's size and digest. | ||
|
|
||
| Wire types and zod schemas live at `@modelcontextprotocol/core-internal/ext/skills` and are re-exported from both subpaths. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| --- | ||
| shape: how-to | ||
| --- | ||
|
|
||
| # Skills (extension) | ||
|
|
||
| The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/). `@modelcontextprotocol/client/ext/skills` is the client side, as a client extension: it lists and gets skill entries, reads directories, and reads skill files verified against their entry. | ||
|
|
||
| ## Install the extension | ||
|
|
||
| ```ts | ||
| import { Client } from '@modelcontextprotocol/client'; | ||
| import { SkillsClientExtension } from '@modelcontextprotocol/client/ext/skills'; | ||
|
|
||
| const skills = new SkillsClientExtension(); | ||
| const client = new Client({ name: 'host', version: '1.0.0' }, { extensions: [skills] }); | ||
| await client.connect(transport); | ||
| ``` | ||
|
|
||
| Every method refuses with `CapabilityNotSupported` unless the server declared the extension and the `resources` capability. `readDirectory` also needs the server to declare `directoryRead`. | ||
|
|
||
| ## List and get skills | ||
|
|
||
| `list` returns one page of entries. Pass `nextCursor` back as `cursor` for the next page. An entry is the full manifest: frontmatter, plus every file with its digest and size. `get(uri)` returns the entry for one skill by the URI of its `SKILL.md`, whether or not the listing included it. | ||
|
|
||
| ```ts | ||
| const { skills: entries, nextCursor } = await skills.list(); | ||
| const { skill } = await skills.get('skill://git-workflow/SKILL.md'); | ||
| ``` | ||
|
|
||
| ## Read a skill file | ||
|
|
||
| `read(skill, uri)` fetches the file with `resources/read` and checks it against the entry. The file must be listed, and its size and SHA-256 digest must match. Any mismatch throws `SdkError` with `InvalidResult`. Refresh the entry with `get` and ask the user to approve the skill again. | ||
|
|
||
| ```ts | ||
| const manifest = await skills.read(skill, skill.uri); | ||
| ``` | ||
|
|
||
| A skill with `resources: 'dynamic'` has nothing to verify against, so its files come back as read. The extension leaves two checks to the host: comparing a `SKILL.md`'s frontmatter with the entry (this needs a YAML parser), and the specification's [security requirements](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx#security-considerations) for loading skills into a model. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| --- | ||
| shape: how-to | ||
| --- | ||
|
|
||
| # Skills (extension) | ||
|
|
||
| The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/): directories of instructions, each with a `SKILL.md`, that a host can load into its model. `@modelcontextprotocol/server/ext/skills` is the server side, as a [server extension](../advanced/extensions.md). It serves `skills/list`, `skills/get` and, optionally, `resources/directory/read`. Your server supplies the skill entries through a `SkillSource`, and serves the skill files as ordinary resources. | ||
|
|
||
| ## Describe a skill | ||
|
|
||
| A skill entry names its `SKILL.md`, repeats that file's frontmatter, and lists every file of the skill with its SHA-256 digest and size. `skillResourceOf(uri, content)` computes a file's line in that list. | ||
|
|
||
| ```ts | ||
| import type { Skill } from '@modelcontextprotocol/server/ext/skills'; | ||
| import { skillResourceOf } from '@modelcontextprotocol/server/ext/skills'; | ||
|
|
||
| const files = { | ||
| 'skill://git-workflow/SKILL.md': '---\nname: git-workflow\ndescription: Follow our Git conventions\n---\n\nBranch from main.\n' | ||
| }; | ||
|
|
||
| const gitWorkflow: Skill = { | ||
| uri: 'skill://git-workflow/SKILL.md', | ||
| frontmatter: { name: 'git-workflow', description: 'Follow our Git conventions' }, | ||
| resources: await Promise.all(Object.entries(files).map(([uri, text]) => skillResourceOf(uri, text))) | ||
| }; | ||
| ``` | ||
|
|
||
| The frontmatter must match the `SKILL.md` exactly, and the URI's last directory must equal `frontmatter.name`. The server checks the second rule, and the entry's other structural rules, and answers `-32603` rather than send an entry that breaks one. A skill generated per request, with no stable digests, sets `resources: 'dynamic'`. | ||
|
|
||
| ## Install the extension | ||
|
|
||
| `SkillsExtension` takes the source. `list` returns a page of entries (an empty or partial listing is allowed). `get` answers for any skill the server serves, listed or not, and returns `undefined` for anything else, which the client receives as `-32602`. | ||
|
|
||
| ```ts | ||
| import { McpServer } from '@modelcontextprotocol/server'; | ||
| import { SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; | ||
|
|
||
| const skills = new SkillsExtension( | ||
| { | ||
| list: () => ({ skills: [gitWorkflow] }), | ||
| get: ({ uri }) => (uri === gitWorkflow.uri ? gitWorkflow : undefined) | ||
| }, | ||
| { cacheHint: { ttlMs: 300_000, cacheScope: 'public' } } | ||
| ); | ||
|
|
||
| const server = new McpServer({ name: 'skills-server', version: '1.0.0' }, { extensions: [skills] }); | ||
|
|
||
| for (const [uri, text] of Object.entries(files)) { | ||
| server.registerResource(uri, uri, { mimeType: 'text/markdown' }, () => ({ contents: [{ uri, mimeType: 'text/markdown', text }] })); | ||
| } | ||
| ``` | ||
|
|
||
| The server declares `io.modelcontextprotocol/skills` under `capabilities.extensions`, along with the `resources` capability the extension requires. `cacheHint` sets the `ttlMs` and `cacheScope` on `skills/list` and `skills/get` results. Both default to `0` and `'private'`. | ||
|
|
||
| ## Serve directory reads | ||
|
|
||
| A source that implements `readDirectory` also gets `resources/directory/read`, and the server declares `directoryRead: true`. It returns the direct children of a directory such as `skill://pdf-processing/templates`, with subdirectories marked `mimeType: 'inode/directory'`, or `undefined` when the URI is not a directory. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| /** | ||
| * `@modelcontextprotocol/client/ext/skills` — the client side of the MCP | ||
| * Skills extension (`io.modelcontextprotocol/skills`, SEP-2640). | ||
| * | ||
| * `SkillsClientExtension` wraps `skills/list`, `skills/get` and | ||
| * `resources/directory/read`, and reads skill files verified against their | ||
| * entry's digest and size. | ||
| */ | ||
|
|
||
| export { SkillsClientExtension } from './skillsClientExtension'; | ||
| export type { | ||
| GetSkillParams, | ||
| GetSkillResult, | ||
| ListSkillsParams, | ||
| ListSkillsResult, | ||
| ReadResourceDirectoryParams, | ||
| ReadResourceDirectoryResult, | ||
| Skill, | ||
| SkillFrontmatter, | ||
| SkillResource, | ||
| SkillsCacheScope, | ||
| SkillsExtensionCapability | ||
| } from '@modelcontextprotocol/core-internal/ext/skills'; | ||
| export { | ||
| getSkillParamsSchema, | ||
| getSkillResultSchema, | ||
| listSkillsParamsSchema, | ||
| listSkillsResultSchema, | ||
| readResourceDirectoryParamsSchema, | ||
| readResourceDirectoryResultSchema, | ||
| skillFrontmatterSchema, | ||
| skillResourceSchema, | ||
| skillSchema, | ||
| skillsExtensionCapabilitySchema | ||
| } from '@modelcontextprotocol/core-internal/ext/skills'; | ||
| export { | ||
| DIRECTORY_MIME_TYPE, | ||
| MAX_SKILL_RESOURCES, | ||
| MAX_SKILL_TOTAL_BYTES, | ||
| SKILL_MANIFEST_FILENAME, | ||
| skillDigest, | ||
| skillResourceOf, | ||
| SKILLS_EXTENSION_ID | ||
|
Comment on lines
+24
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): maintainers take on a wider public API than the feature needs, since both subpaths re-export every zod schema and constant from core-internal. The client subpath at packages/client/src/ext/skills/index.ts:24-43 exports params schemas ( Why this was flaggedA user imports Verification: nit. Triggering condition: any consumer of the new subpaths sees the full re-exported surface. Mechanism verified: packages/client/src/ext/skills/index.ts:24-44 re-exports all ten zod schemas and all five constants; packages/server/src/ext/skills/index.ts:26-46 does the same. Nothing fails at runtime; this is API-surface commitment only, hence nit. |
||
| } from '@modelcontextprotocol/core-internal/ext/skills'; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| /** | ||
| * `SkillsClientExtension` — the client side of the MCP Skills extension | ||
| * (`io.modelcontextprotocol/skills`, SEP-2640) as a {@linkcode ClientExtension}. | ||
| * It wraps `skills/list`, `skills/get` and `resources/directory/read`, each | ||
| * refused unless the server declared support, and `read`, which fetches a | ||
| * skill file with `resources/read` and verifies it against the skill's entry. | ||
| * | ||
| * ```ts | ||
| * const skills = new SkillsClientExtension(); | ||
| * const client = new Client(info, { extensions: [skills] }); | ||
| * await client.connect(transport); | ||
| * | ||
| * const { skills: entries } = await skills.list(); | ||
| * const manifest = await skills.read(entries[0], entries[0].uri); | ||
| * ``` | ||
| * | ||
| * One extension instance serves one client: `install` binds it. | ||
| */ | ||
|
|
||
| import type { BlobResourceContents, RequestOptions, TextResourceContents } from '@modelcontextprotocol/core-internal'; | ||
| import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; | ||
| import type { | ||
| GetSkillResult, | ||
| ListSkillsParams, | ||
| ListSkillsResult, | ||
| ReadResourceDirectoryResult, | ||
| Skill, | ||
| SkillsExtensionCapability | ||
| } from '@modelcontextprotocol/core-internal/ext/skills'; | ||
| import { | ||
| getSkillResultSchema, | ||
| listSkillsResultSchema, | ||
| readResourceDirectoryResultSchema, | ||
| skillDigest, | ||
| skillFileBytes, | ||
| SKILLS_EXTENSION_ID, | ||
| skillsExtensionCapabilitySchema | ||
| } from '@modelcontextprotocol/core-internal/ext/skills'; | ||
|
|
||
| import type { Client } from '../../client/client'; | ||
| import type { ClientExtension } from '../../client/extension'; | ||
|
|
||
| const verificationFailure = (uri: string, reason: string): never => { | ||
| throw new SdkError(SdkErrorCode.InvalidResult, `Skill file ${uri} failed verification: ${reason}`); | ||
| }; | ||
|
|
||
| const rawBytes = (content: TextResourceContents | BlobResourceContents): Uint8Array => | ||
| 'blob' in content ? Uint8Array.from(atob(content.blob), char => char.codePointAt(0) ?? 0) : skillFileBytes(content.text); | ||
|
|
||
| export class SkillsClientExtension implements ClientExtension { | ||
| readonly id = SKILLS_EXTENSION_ID; | ||
| #client: Client | undefined; | ||
|
|
||
| install(client: Client): void { | ||
| if (this.#client !== undefined) throw new Error('SkillsClientExtension is already installed on a client'); | ||
| this.#client = client; | ||
| } | ||
|
|
||
| get client(): Client { | ||
| if (this.#client === undefined) throw new SdkError(SdkErrorCode.NotConnected, 'SkillsClientExtension is not installed on a client'); | ||
| return this.#client; | ||
| } | ||
|
|
||
| /** The extension settings the server declared, or `undefined` when it declared none. */ | ||
| get capability(): SkillsExtensionCapability | undefined { | ||
| const declared = this.client.getServerCapabilities()?.extensions?.[SKILLS_EXTENSION_ID]; | ||
| const parsed = skillsExtensionCapabilitySchema.safeParse(declared); | ||
| return parsed.success ? parsed.data : undefined; | ||
| } | ||
|
|
||
| /** `skills/list`: one page of entries. Pass `nextCursor` back as `cursor` for the next. */ | ||
| async list(params?: ListSkillsParams, options?: RequestOptions): Promise<ListSkillsResult> { | ||
| this.#require('skills/list'); | ||
| return (await this.client.request( | ||
| { method: 'skills/list', params: { ...params } }, | ||
| listSkillsResultSchema, | ||
| options | ||
| )) as ListSkillsResult; | ||
| } | ||
|
|
||
| /** `skills/get`: the entry for the skill whose `SKILL.md` is at `uri`, listed or not. */ | ||
| async get(uri: string, options?: RequestOptions): Promise<GetSkillResult> { | ||
| this.#require('skills/get'); | ||
| return (await this.client.request({ method: 'skills/get', params: { uri } }, getSkillResultSchema, options)) as GetSkillResult; | ||
| } | ||
|
|
||
| /** `resources/directory/read`: the direct children of a directory resource. Refused unless the server declared `directoryRead`. */ | ||
| async readDirectory(uri: string, cursor?: string, options?: RequestOptions): Promise<ReadResourceDirectoryResult> { | ||
| this.#require('resources/directory/read'); | ||
| if (this.capability?.directoryRead !== true) { | ||
| throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Server does not support resources/directory/read'); | ||
| } | ||
| const params = { uri, ...(cursor !== undefined && { cursor }) }; | ||
| return (await this.client.request( | ||
| { method: 'resources/directory/read', params }, | ||
| readResourceDirectoryResultSchema, | ||
| options | ||
| )) as ReadResourceDirectoryResult; | ||
| } | ||
|
|
||
| /** | ||
| * Reads one of `skill`'s files and verifies it against the entry: the file | ||
| * must be listed, and its size and digest must match. Throws `InvalidResult` | ||
| * on any mismatch. A `"dynamic"` skill has nothing to verify against, so its | ||
| * files are returned as read. Comparing a `SKILL.md`'s frontmatter with the | ||
| * entry needs a YAML parser and is left to the host. | ||
| */ | ||
| async read(skill: Skill, uri: string, options?: RequestOptions): Promise<TextResourceContents | BlobResourceContents> { | ||
| const listed = skill.resources === 'dynamic' ? undefined : skill.resources.find(resource => resource.uri === uri); | ||
| if (skill.resources !== 'dynamic' && listed === undefined) verificationFailure(uri, `not listed in ${skill.uri}`); | ||
|
|
||
| const { contents } = await this.client.readResource({ uri }, options); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Hosts whose client cached a skill file keep getting Why this was flaggedA server registers a skill file with Verification: normal — when a server attaches a positive |
||
| const content = contents.find(item => item.uri === uri) ?? verificationFailure(uri, 'not in the resources/read result'); | ||
| if (listed === undefined) return content; | ||
|
|
||
| const bytes = rawBytes(content); | ||
| if (bytes.byteLength !== listed.size) verificationFailure(uri, `size ${bytes.byteLength}, entry says ${listed.size}`); | ||
| if ((await skillDigest(bytes)) !== listed.digest) verificationFailure(uri, 'digest mismatch'); | ||
| return content; | ||
| } | ||
|
|
||
| #require(method: string): void { | ||
| if (this.capability === undefined) { | ||
| throw new SdkError( | ||
| SdkErrorCode.CapabilityNotSupported, | ||
| `Server does not support the ${SKILLS_EXTENSION_ID} extension (${method})` | ||
| ); | ||
| } | ||
| if (this.client.getServerCapabilities()?.resources === undefined) { | ||
| throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server declares ${SKILLS_EXTENSION_ID} without resources (${method})`); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import type { SkillResource } from './types'; | ||
|
|
||
| const encoder = new TextEncoder(); | ||
|
|
||
| /** The raw bytes of a file: UTF-8 for text. */ | ||
| export function skillFileBytes(content: string | Uint8Array): Uint8Array { | ||
| return typeof content === 'string' ? encoder.encode(content) : content; | ||
| } | ||
|
|
||
| /** `sha256:{hex}` of raw bytes, the digest format of a `SkillResource`. */ | ||
| export async function skillDigest(bytes: Uint8Array): Promise<string> { | ||
| const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as Uint8Array<ArrayBuffer>)); | ||
| return `sha256:${Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('')}`; | ||
| } | ||
|
|
||
| /** The `SkillResource` entry for a file: its URI, digest and size. */ | ||
| export async function skillResourceOf(uri: string, content: string | Uint8Array): Promise<SkillResource> { | ||
| const bytes = skillFileBytes(content); | ||
| return { uri, digest: await skillDigest(bytes), size: bytes.byteLength }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit (optional): readers of the client docs are told every method is refused unless the server declared the extension, but
readis not gated. docs/clients/skills.md:20 says "Every method refuses withCapabilityNotSupported", whilereadat packages/client/src/ext/skills/skillsClientExtension.ts:108 never calls#requireand issuesresources/readregardless of what the server declared. Fix: make the words match the code, either by sayinglist,getandreadDirectoryare refused (as the changeset and the class JSDoc already do) or by gatingreadon the same check. [also at: docs/clients/skills.md:20 - nit: CLAUDE.md asks that docs say what the code does: this line states "Every method refuses withCapabilityNotSupportedunless the server declared the extension and theresourcescapability", butSkillsClientExtension.read()(packages/client/src/ext/skills/skillsClientExtension.ts:108-120) never calls#require— it goes straight toclient.readResource, so a server that declaredresourcesbut not the skills extension is read from without refusal.]Why this was flagged
A user reads docs/clients/skills.md:20 and expects
skills.read(skill, uri)to throwCapabilityNotSupportedagainst a server that did not declareio.modelcontextprotocol/skills. The method at packages/client/src/ext/skills/skillsClientExtension.ts:108-120 performs no capability check and callsthis.client.readResourcedirectly, so the call proceeds and either succeeds or fails with whateverresources/readreturns. The changeset and the class JSDoc at skillsClientExtension.ts:4-6 describe onlylist,getandreadDirectoryas refused, so the docs page alone is wrong. Nothing breaks at runtime; the doc misstates the behaviour.Verification: nit. The doc line is docs/clients/skills.md:20: "Every method refuses with
CapabilityNotSupportedunless the server declared the extension and theresourcescapability." In packages/client/src/ext/skills/skillsClientExtension.ts,list(line 73),get(line 83) andreadDirectory(line 89) callthis.#require(...), butread(lines 108-120) never does. Nothing breaks at runtime, so this is a documentation inaccuracy.