diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe774efe39..49158c2deb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,7 @@ Details about this repository that neither the code nor the tools will tell you. - **Adapters** — The packages under `packages/middleware/` are thin adapters, versioned separately from client and server, so a user can run a new server with an older adapter. - **Package roots** — A package's root entry stays runtime-neutral so browser and Workers bundlers can use it; Node-only code lives at a subpath export such as `./stdio`. - **Migration guide** — `docs/migration/upgrade-to-v2.md` covers the upgrade from v1 only. Its text lives in the agent skill `skills/mcp-typescript-sdk-upgrade-to-v2/` (one `references/` file per subsystem), which the page includes, so edit it there; mechanical renames go in `packages/codemod/src/migrations/v1-to-v2/mappings/`. +- **Skills guides** — The text of `docs/servers/skills.md` and `docs/clients/skills.md` lives in the agent skill `skills/mcp-typescript-sdk-skills/` (`references/server.md` and `references/client.md`), which the pages include, so edit it there. ### Running Examples diff --git a/docs/.vitepress/skill.ts b/docs/.vitepress/skill.ts index 5817252a78..0af8b409e7 100644 --- a/docs/.vitepress/skill.ts +++ b/docs/.vitepress/skill.ts @@ -1,12 +1,22 @@ -import { readdirSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { dirname, posix, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -/** The docs page assembled with `` from the skill in `skills/mcp-typescript-sdk-upgrade-to-v2/`. */ -export const SKILL_PAGE = 'migration/upgrade-to-v2.md'; +const docsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const skillDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../skills/mcp-typescript-sdk-upgrade-to-v2'); -const references = new Set(readdirSync(resolve(skillDir, 'references'))); +/** A skill file as links inside the skill name it: `SKILL.md`, or a reference's bare file name. */ +function skillFile(path: string): string { + return /^(?:\.\.\/)?SKILL\.md$/.test(path) ? 'SKILL.md' : path.replace(/^references\//, ''); +} + +/** The docs pages assembled with `` from a skill in `skills/`, each with the skill files it includes. */ +const SKILL_PAGES: ReadonlyMap> = new Map( + ['migration/upgrade-to-v2.md', 'servers/skills.md', 'clients/skills.md'].map(page => { + const source = readFileSync(resolve(docsDir, page), 'utf8'); + const includes = source.matchAll(//g); + return [page, new Set([...includes].map(([, path]) => skillFile(path!)))]; + }) +); /** * GitHub's heading slug (github-slugger) for a heading's markdown text — the @@ -21,9 +31,10 @@ export function githubSlug(heading: string): string { .replaceAll(' ', '-'); } -/** Rewrite one link target on the skill-assembled page; every other page and link passes through. */ +/** Rewrite one link target on a skill-assembled page; every other page and link passes through. */ export function rewriteSkillLink(href: string, page: string, site: string): string { - if (page !== SKILL_PAGE) return href; + const files = SKILL_PAGES.get(page); + if (!files) return href; const hashIndex = href.indexOf('#'); const path = hashIndex === -1 ? href : href.slice(0, hashIndex); const hash = hashIndex === -1 ? '' : href.slice(hashIndex); @@ -31,6 +42,5 @@ export function rewriteSkillLink(href: string, page: string, site: string): stri const relative = posix.relative(posix.dirname(page), path.slice(site.length + 1)); return `${relative.startsWith('../') ? relative : `./${relative}`}${hash}`; } - const file = /^(?:\.\.\/)?SKILL\.md$/.test(path) ? 'SKILL.md' : path.replace(/^references\//, ''); - return hash && (file === 'SKILL.md' || references.has(file)) ? hash : href; + return hash && files.has(skillFile(path)) ? hash : href; } diff --git a/docs/_meta/CONVENTIONS.md b/docs/_meta/CONVENTIONS.md index 5dc509dbab..eac80915c8 100644 --- a/docs/_meta/CONVENTIONS.md +++ b/docs/_meta/CONVENTIONS.md @@ -198,7 +198,7 @@ So a page at `docs/get-started/first-server.md` whose companion is source="../../examples/guides/get-started/firstServer.examples.ts#" ``` -The sync script scans `docs/**/*.md`. +The sync script scans `docs/**/*.md` and `skills/**/*.md`. ### 4. How example files are typechecked @@ -230,7 +230,7 @@ pnpm sync:snippets --check ``` NEVER run bare `pnpm sync:snippets` — it rewrites every fenced block in `docs/` and -`docs/` in place, and concurrent mutating runs from parallel agents race and clobber +`skills/` in place, and concurrent mutating runs from parallel agents race and clobber each other. Hand-write the fence body to match the region exactly; `--check` confirms. Verified outputs from prep (2026-06-29): diff --git a/docs/clients/skills.md b/docs/clients/skills.md index 418025b61e..27a1cb3a5a 100644 --- a/docs/clients/skills.md +++ b/docs/clients/skills.md @@ -1,153 +1,17 @@ --- shape: how-to +source: 'This page is assembled from skills/mcp-typescript-sdk-skills/references/client.md, so edit that file. Read it rendered at https://ts.sdk.modelcontextprotocol.io/v2/clients/skills' --- # Skills -`SkillsClient` lists a server's skills and reads their files, checking each file against what the server published. + -```ts source="../../examples/guides/clients/skills.examples.ts#SkillsClient_list" -import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; +::: tip Use it with a coding agent +This guide is also published as an agent skill, covering both serving and consuming skills: -const skills = new SkillsClient(client); -const { skills: entries } = await skills.list(); -console.log(entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter.description}`).join('\n')); +```bash +npx skills add modelcontextprotocol/typescript-sdk --skill mcp-typescript-sdk-skills ``` -Against a server with two skills, it prints each skill's name and description: - -```text -refunds: Process customer refunds. -git-workflow: Branching and review conventions. -``` - -Listing reads no skill file: each **entry** carries the skill's frontmatter and its file list with digests, enough to decide which skill to load. - -## Handle refused entries - -Create the client with `skipInvalid: true` to keep the valid skills and log the refused entries. - -```ts source="../../examples/guides/clients/skills.examples.ts#list_invalid" -const tolerant = new SkillsClient(client, { skipInvalid: true }); -const { invalid } = await tolerant.list(); -for (const refused of invalid) console.error(`skills: refused entry #${refused.index} ${refused.uri ?? ''}: ${refused.reason}`); -``` - -By default `list()` throws an `SdkError` naming every entry that fails validation, and every copy of a repeated URI. With `skipInvalid: true` it returns the valid skills and puts those entries in `invalid`, each with `index`, `uri` when it has one, and `reason`. Against this server it is empty, so the loop prints nothing. - -## Load a skill - -Read the entry's `SKILL.md`. - -```ts source="../../examples/guides/clients/skills.examples.ts#read_manifest" -const git = entries.find(entry => entry.frontmatter.name === 'git-workflow')!; -const manifest = await skills.read(git); -console.log(manifest.verified, manifest.frontmatter); -``` - -The file passes every check, and its frontmatter matches the entry: - -```text -true { - name: 'git-workflow', - description: 'Branching and review conventions.' -} -``` - -`read(entry)` checks the size and SHA-256 digest against the entry, then compares the frontmatter field by field. `manifest.body` holds the instructions to give the model. - -The comparison parses the frontmatter with YAML 1.2 core-schema typing, so `yes` is the string `"yes"`, not `true`. If a server builds its entries with a parser that types values differently, pass a matching YAML parser as `parseFrontmatter` to `new SkillsClient`; it receives the YAML between the `---` lines. - -## Read the files a skill references - -Pass the path `SKILL.md` uses. - -```ts source="../../examples/guides/clients/skills.examples.ts#read_reference" -const review = await skills.read(git, 'references/REVIEW.md'); -console.log(review.text); -``` - -`review.text` holds the verified file: - -```text -# Review checklist - -- Tests pass. - -``` - -A relative path resolves against the skill's folder, as a link in `SKILL.md` does, and `read` ignores a fragment. It matches the path to the entry's file list however either side percent-encodes the name, so `references/Q&A.md` works. `bytes` always holds the file's bytes; `text` holds the file's text when the server sent it as text, and always for the skill's own `SKILL.md`. - -## Handle verification failures - -Catch `SkillVerificationError`. - -```ts source="../../examples/guides/clients/skills.examples.ts#SkillVerificationError" -import { SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; - -try { - await skills.read(git, 'references/MISSING.md'); -} catch (error) { - if (!(error instanceof SkillVerificationError)) throw error; - console.log(error.reason, error.uri); -} -``` - -The entry does not list `references/MISSING.md`, so `read` refuses it before sending any request: - -```text -unlisted skill://git-workflow/references/MISSING.md -``` - -`reason` is `unlisted`, `size`, `digest` or `frontmatter`; a path that does not parse is `unlisted` too. When a size, digest or frontmatter check fails on a copy the client served from its response cache, `read` fetches the file once more with `cacheMode: 'refresh'` and throws only if the fresh copy fails as well; a copy that came from the server fails at once. On any reason, do not use the file; for `SKILL.md`, do not load the skill. - -## Browse a skill folder - -List a folder's direct children when the server declares `directoryRead`. - -```ts source="../../examples/guides/clients/skills.examples.ts#readDirectory" -if (skills.directoryRead) { - const { resources: children } = await skills.readDirectory('skill://acme/billing/refunds'); - console.log(children.map(child => `${child.name} ${child.mimeType}`).join('\n')); -} -``` - -The `refunds` folder holds its `SKILL.md` and a `templates` folder: - -```text -SKILL.md text/markdown -templates inode/directory -``` - -## Get a skill by URI - -Fetch one entry, listed or not. - -```ts source="../../examples/guides/clients/skills.examples.ts#get" -const { skill: refunds } = await skills.get('skill://acme/billing/refunds/SKILL.md'); -console.log(refunds.frontmatter.name); -``` - -It prints the skill's name from the entry's frontmatter: - -```text -refunds -``` - -On 2026-07-28, `list()`, `get()` and `readDirectory()` also return the server's caching hints, `ttlMs` and `cacheScope`, and throw an `SdkError` when a response leaves either out. - -::: info Dynamic skills -An entry whose `resources` is `"dynamic"` has no file list. `read` then skips the size and digest checks and returns `verified: false`, but still compares the `SKILL.md` frontmatter. ::: - -::: warning Host responsibilities -Accept skills up to `SKILL_LIMITS` (512 files, 16 MiB) and explain why you decline one, get approval before you load a skill from a server, and record which server each skill came from. Read a skill's files when you load it, never while listing. -::: - -## Recap - -- `new SkillsClient(client)` wraps a connected client; `list()` returns entries without reading files. -- `list()` throws on an invalid entry; `skipInvalid: true` keeps the valid skills and returns the refused ones in `invalid`. -- `read(entry)` verifies `SKILL.md` (size, digest, frontmatter) and returns its frontmatter and body. -- `read(entry, path)` resolves a path against the skill's folder and verifies that file. -- A failed check throws `SkillVerificationError` with a `reason`. diff --git a/docs/servers/skills.md b/docs/servers/skills.md index b3388d42c9..afa8aa46a5 100644 --- a/docs/servers/skills.md +++ b/docs/servers/skills.md @@ -1,196 +1,17 @@ --- shape: how-to +source: 'This page is assembled from skills/mcp-typescript-sdk-skills/references/server.md, so edit that file. Read it rendered at https://ts.sdk.modelcontextprotocol.io/v2/servers/skills' --- # Skills -`loadSkills` reads a folder of skills once; `attach` serves it on a server. + -```ts source="../../examples/guides/servers/skills.examples.ts#loadSkills_basic" -import { McpServer } from '@modelcontextprotocol/server'; -import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; +::: tip Use it with a coding agent +This guide is also published as an agent skill, covering both serving and consuming skills: -const skills = await loadSkills('./skills'); - -const server = new McpServer({ name: 'team-skills', version: '1.0.0' }); -skills.attach(server); -``` - -A **skill** is a folder with a `SKILL.md` and the files it references (SEP-2640). Clients list skills with `skills/list` and read each file with `resources/read`, checking it against the digest your server published. - -## Lay out the skills folder - -Each folder that contains a `SKILL.md` is one skill; other folders only group skills in the URI. - -```text -skills/ -├── git-workflow/ -│ ├── SKILL.md -│ └── references/REVIEW.md -└── acme/billing/refunds/ - └── SKILL.md -``` - -`SKILL.md` starts with YAML frontmatter holding `name` (the same as the skill's folder name) and `description`. Print the URIs the catalog publishes: - -```ts source="../../examples/guides/servers/skills.examples.ts#entries" -console.log(skills.entries.map(skill => skill.uri).join('\n')); -``` - -It prints one `SKILL.md` URI per skill, sorted: - -```text -skill://acme/billing/refunds/SKILL.md -skill://git-workflow/SKILL.md -``` - -## Serve one catalog from every server - -Attach the same catalog to each server your handler creates. - -```ts source="../../examples/guides/servers/skills.examples.ts#attach_perRequest" -import { createMcpHandler } from '@modelcontextprotocol/server'; - -const handler = createMcpHandler(() => { - const mcp = new McpServer({ name: 'team-skills', version: '1.0.0' }); - skills.attach(mcp); - return mcp; -}); -``` - -The catalog keeps metadata only (paths, digests, sizes, frontmatter); each read goes to the folder, and the SDK serves the bytes only if they still match the published digest. It sends a file as `text` whenever its bytes are valid UTF-8 (a BOM included), whatever its MIME type, and as a base64 `blob` otherwise. - -## Pick up changes with `reload()` - -Rescan the folder and swap the new catalog in at once. - -```ts source="../../examples/guides/servers/skills.examples.ts#reload" -const changes = await skills.reload(); -console.log(changes); -``` - -With nothing changed on disk, `reload()` reports no changes: - -```text -{ added: [], removed: [], changed: [], listChanged: false } -``` - -Until you call `reload()`, the server answers a read of a file edited on disk with an error instead of unverified content. If the rescan fails, `reload()` rejects and the old catalog keeps serving. - -`listChanged` is `true` when a skill appears, disappears, or changes its name or description. A server that stays connected (on stdio, or in a stateful 2025-11-25 HTTP session) then sends its clients `notifications/resources/list_changed` itself. A server that `createMcpHandler` builds lives for one request, so publish the change on the handler instead: - -```ts source="../../examples/guides/servers/skills.examples.ts#reload_notify" -if (changes.listChanged) handler.notify.resourcesChanged(); +```bash +npx skills add modelcontextprotocol/typescript-sdk --skill mcp-typescript-sdk-skills ``` -Every open `subscriptions/listen` stream that opted in to resource list changes receives the notification. - -## Serve skills defined in code - -Pass the files to `memorySkillFiles` where there is no file system, such as Workers or tests. - -```ts source="../../examples/guides/servers/skills.examples.ts#memorySkillFiles" -import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; - -const RELEASE_NOTES_MD = `--- -name: release-notes -description: Write release notes from merged pull requests. ---- -# Release notes -`; - -const inline = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': RELEASE_NOTES_MD })); -``` - -`memorySkillFiles` re-reads the object you pass whenever the catalog rescans (at load and on `reload()`), so `reload()` sees files you add or change later. - -## Serve skills from your own storage - -Implement `SkillFiles`, two functions over relative paths, to load from any store. - -```ts source="../../examples/guides/servers/skills.examples.ts#skillFiles_custom" -import type { SkillFiles } from '@modelcontextprotocol/server/ext/skills'; - -// Two operations over '/'-separated paths, '' being the root: here, a key-value store of objects. -function bucketSkillFiles(bucket: ReadonlyMap): SkillFiles { - return { - async list(dir) { - const prefix = dir === '' ? '' : `${dir}/`; - const children = new Map(); - for (const key of bucket.keys()) { - if (!key.startsWith(prefix)) continue; - const [name, ...rest] = key.slice(prefix.length).split('/'); - if (name !== undefined) children.set(name, rest.length > 0 ? 'directory' : 'file'); - } - return [...children].map(([name, kind]) => ({ name, kind })); - }, - async read(key) { - const bytes = bucket.get(key); - if (bytes === undefined) throw new Error(`no object ${key}`); - return bytes; - } - }; -} - -const fromBucket = await SkillsExtension.load( - bucketSkillFiles(new Map([['release-notes/SKILL.md', new TextEncoder().encode(RELEASE_NOTES_MD)]])) -); -``` - -The catalog logic (discovery, digests, rule checks, paging, verified reads) is the same for every adapter. Report a symlink or other special file as `kind: 'unsupported'`; the catalog skips the skill that contains one. - -## Generate skills with a `SkillSource` - -Build entries with `skillEntry` and serve them from a `SkillSource` when the catalog depends on the request. - -```ts source="../../examples/guides/servers/skills.examples.ts#skillSource" -import type { SkillSource } from '@modelcontextprotocol/server/ext/skills'; -import { skillEntry } from '@modelcontextprotocol/server/ext/skills'; - -const ON_CALL_MD = `--- -name: on-call -description: Handle a page while on call. ---- -# On call -`; -const onCall = await skillEntry('on-call', { 'SKILL.md': ON_CALL_MD }); - -const source: SkillSource = { - list: async () => ({ skills: [onCall] }), - get: async uri => (uri === onCall.uri ? onCall : undefined), - read: async uri => (uri === onCall.uri ? { text: ON_CALL_MD, mimeType: 'text/markdown' } : undefined) -}; -const generated = new SkillsExtension({ source }); -``` - -Every source method receives the request context (`ctx`), so you can list different skills per caller. Add `readDirectory` to serve folder listings, and `listResources` to list skills in `resources/list`. - -## Handle invalid skills - -Route warnings where you want them; by default they go to stderr. - -```ts source="../../examples/guides/servers/skills.examples.ts#onWarning" -const checked = await loadSkills('./skills', { - onWarning: ({ path, message }) => console.error(`skills: ${path}: ${message}`) -}); -``` - -`loadSkills` skips, with a warning, any skill that breaks the rules, such as a `name` that differs from its folder or a symlinked file; the other skills still serve. Skills over 512 files or 16 MiB still publish, with a warning; `limits` changes those thresholds. The same options take `prefix` (`'acme'` serves every skill under `skill://acme/…`) and `pageSize` (entries per `skills/list` and directory page, default 100). - -Set `strict: true` to make `loadSkills` throw a `SkillCatalogError` listing every problem instead. Under `strict: true`, a symlink or other special file anywhere in the folder fails the load, even one outside every skill; hidden entries (names starting with `.`) are the exception, since the scan skips them entirely. - -::: warning -Do not register your own resources under `skill://`. A resource you register at a skill file's URI takes precedence over that file, and clients then fail to verify it. ::: - -::: info -Skills work on both protocol versions; the `cacheHint` option applies on 2026-07-28 only — see [Protocol versions](../protocol-versions.md). -::: - -## Recap - -- `loadSkills('./skills')` builds a catalog once; `attach(server)` serves it on any number of servers. -- Each folder with a `SKILL.md` is a skill; other folders group skills in the URI. -- `reload()` swaps in a rescanned catalog all at once; behind `createMcpHandler`, call `handler.notify.resourcesChanged()` when it reports `listChanged`. -- `memorySkillFiles`, a `SkillFiles` adapter or a `SkillSource` serve skills without a folder. -- `loadSkills` skips invalid skills with a warning; `strict: true` makes it throw instead. diff --git a/examples/guides/README.md b/examples/guides/README.md index 30975e7c41..965d081335 100644 --- a/examples/guides/README.md +++ b/examples/guides/README.md @@ -1,3 +1,3 @@ # guides -Per-page snippet companions for the `docs/` guide pages: each `/.examples.ts` is the type-checked source for the code fences in `docs//.md`, synced via `pnpm sync:snippets`. Companions that quote output are also run as real programs by `pnpm docs:examples`; the rest opt out with a `// docs: typecheck-only` first line. +Per-page snippet companions for the `docs/` guide pages: each `/.examples.ts` is the type-checked source for the code fences in `docs//.md` or, for a page assembled from an agent skill, in that skill's files under `skills/`, synced via `pnpm sync:snippets`. Companions that quote output are also run as real programs by `pnpm docs:examples`; the rest opt out with a `// docs: typecheck-only` first line. diff --git a/examples/guides/clients/skills.examples.ts b/examples/guides/clients/skills.examples.ts index d4412df94e..7c2a86d651 100644 --- a/examples/guides/clients/skills.examples.ts +++ b/examples/guides/clients/skills.examples.ts @@ -1,7 +1,8 @@ /** - * Companion example for `docs/clients/skills.md`. + * Companion example for `skills/mcp-typescript-sdk-skills/references/client.md`, + * which renders as `docs/clients/skills.md`. * - * Every `ts` fence on that page is synced from a `//#region` in this file + * Every `ts` fence in that file is synced from a `//#region` in this file * (`pnpm sync:snippets --check`). The file also runs (`pnpm docs:examples`) * against an in-memory server and prints the output the page quotes. * diff --git a/examples/guides/servers/skills.examples.ts b/examples/guides/servers/skills.examples.ts index ecef49c604..4bfb84cd55 100644 --- a/examples/guides/servers/skills.examples.ts +++ b/examples/guides/servers/skills.examples.ts @@ -1,7 +1,8 @@ /** - * Companion example for `docs/servers/skills.md`. + * Companion example for `skills/mcp-typescript-sdk-skills/references/server.md`, + * which renders as `docs/servers/skills.md`. * - * Every `ts` fence on that page is synced from a `//#region` in this file + * Every `ts` fence in that file is synced from a `//#region` in this file * (`pnpm sync:snippets --check`). The file also runs (`pnpm docs:examples`) * and prints the output the page quotes. * @@ -121,12 +122,32 @@ const checked = await loadSkills('./skills', { }); //#endregion onWarning +//#region verify_inMemory +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +const client = new Client({ name: 'skills-check', version: '1.0.0' }); +await client.connect(clientTransport); + +const skillsClient = new SkillsClient(client); +const { skills: listed } = await skillsClient.list(); +if (listed.length === 0) throw new Error('the server listed no skills'); +for (const entry of listed) { + const files = entry.resources === 'dynamic' ? [entry.uri] : entry.resources.map(file => file.uri); + for (const uri of files) await skillsClient.read(entry, uri); +} +console.log(`${listed.length} skill(s) listed and verified`); +//#endregion verify_inMemory + // Claims the page makes, checked on every run. const QUOTED_URIS = ['skill://acme/billing/refunds/SKILL.md', 'skill://git-workflow/SKILL.md']; if (JSON.stringify(skills.entries.map(skill => skill.uri)) !== JSON.stringify(QUOTED_URIS)) { throw new Error('skills.md: the catalog must publish the quoted URIs, sorted'); } if (checked.entries.length !== 2) throw new Error('skills.md: expected two skills'); +if (listed.length !== 2) throw new Error('SKILL.md: the in-memory check must list and verify both skills'); if (JSON.stringify(changes) !== JSON.stringify({ added: [], removed: [], changed: [], listChanged: false })) { throw new Error('skills.md: reload claim failed'); } diff --git a/scripts/sync-snippets.ts b/scripts/sync-snippets.ts index fd1ffc6932..e28bb293da 100644 --- a/scripts/sync-snippets.ts +++ b/scripts/sync-snippets.ts @@ -1,8 +1,8 @@ /** * Code Snippet Sync Script * - * This script syncs code snippets into JSDoc comments and markdown files - * containing labeled code fences. + * This script syncs code snippets into JSDoc comments and into markdown files + * (under docs/ and skills/) containing labeled code fences. * * ## Supported Source Files * @@ -55,6 +55,7 @@ const __dirname = dirname(__filename); const PROJECT_ROOT = join(__dirname, '..'); const PACKAGES_DIR = join(PROJECT_ROOT, 'packages'); const DOCS_DIR = join(PROJECT_ROOT, 'docs'); +const SKILLS_DIR = join(PROJECT_ROOT, 'skills'); /** Processing mode based on file type */ type FileMode = 'jsdoc' | 'markdown'; @@ -577,8 +578,8 @@ async function main() { } } - // Process markdown documentation files - const markdownFiles = findMarkdownFiles(DOCS_DIR); + // Process markdown files: the docs and the agent skills + const markdownFiles = [...findMarkdownFiles(DOCS_DIR), ...findMarkdownFiles(SKILLS_DIR)]; for (const filePath of markdownFiles) { const result = processFile(filePath, cache, 'markdown', { check: checkMode }); results.push(result); diff --git a/skills/mcp-typescript-sdk-skills/SKILL.md b/skills/mcp-typescript-sdk-skills/SKILL.md new file mode 100644 index 0000000000..131b572e79 --- /dev/null +++ b/skills/mcp-typescript-sdk-skills/SKILL.md @@ -0,0 +1,166 @@ +--- +name: mcp-typescript-sdk-skills +description: 'Serves and consumes Skills over MCP (SEP-2640, the `io.modelcontextprotocol/skills` extension) with v2 of the MCP TypeScript SDK: publishes a folder of `SKILL.md` skills from an MCP server with `loadSkills` and `SkillsExtension`, and lists, verifies and loads them in a client or host with `SkillsClient`. Use whenever a TypeScript project on `@modelcontextprotocol/server` or `@modelcontextprotocol/client` 2.x wants to expose agent skills from its MCP server, serve `skill://` resources or `skills/list`, or load skills from the MCP servers it connects to, even if the user does not say "SEP-2640" or "extension". Not for projects still on v1 (`@modelcontextprotocol/sdk`: upgrade them with the `mcp-typescript-sdk-upgrade-to-v2` skill first), for MCP SDKs in other languages (Python, Go, C#), or for writing the content of a `SKILL.md`.' +--- + +# Skills over MCP with the TypeScript SDK + +Skills over MCP lets an MCP server publish agent skills (folders with a `SKILL.md`) and lets clients list, verify and load them. In v2 of the TypeScript SDK it ships as the `ext/skills` subpaths of `@modelcontextprotocol/server` and `@modelcontextprotocol/client`. + +The goal is working, verified code in the user's project. Use only the APIs shown here and in the references, and skip explaining the protocol unless the user asks. + +## Which side the project is on + +Read `package.json` and the source before writing anything: + +- **Server:** depends on `@modelcontextprotocol/server` and creates an `McpServer`, directly or in a factory passed to `serveStdio` or `createMcpHandler`. It publishes skills: follow [Serve skills](#serve-skills). +- **Client or host:** depends on `@modelcontextprotocol/client` and creates a `Client`. It loads skills from the servers it connects to: follow [Use skills](#use-skills). +- **Both** (a host that also runs a server, a gateway): follow both, each against its own code. A `Client` that exists only in the project's tests does not make it a host: that is where [Verify](#verify) runs. +- **v1** (`@modelcontextprotocol/sdk`): Skills over MCP exists only in v2. Upgrade first with the `mcp-typescript-sdk-upgrade-to-v2` skill, then come back. + +## Check that the installed SDK has skills + +Skills over MCP ships in the `./ext/skills` export of the v2 packages, and an import of a subpath the installed package lacks fails only at build or run time. Check first, from the package that depends on the SDK (use `client` in place of `server` for a client): + +```bash +grep -c '"./ext/skills"' node_modules/@modelcontextprotocol/server/package.json +``` + +If it prints `0`, check the latest release: + +```bash +npm view @modelcontextprotocol/server@latest exports --json | grep -c '"./ext/skills"' +``` + +- **The latest release has it:** upgrade (`server` and `client` to the same version, if the project uses both) and continue. +- **It does not:** stop here. Tell the user that Skills over MCP is not in a released version of the SDK yet, and that this works once a release exports `@modelcontextprotocol/server/ext/skills`. Do not write `skills/list` handlers or `skill://` resources by hand as a workaround. Clients verify every file against the digests and frontmatter the extension publishes, and the extension also declares the capability, pages the catalog and handles both protocol versions, so a hand-written copy is easy to get wrong in ways those clients refuse, and drifts from the spec. + +## Serve skills + +**Pick where the skills come from.** Each source gives you an extension that you attach as shown below; only the first three have `reload()`: + +- **A folder on disk** (Node): `loadSkills`, which the steps below walk through. +- **Skills defined in code** (Workers and other runtimes without a file system, tests): `memorySkillFiles`. See [Serve skills defined in code](references/server.md#serve-skills-defined-in-code). +- **Your own storage** (an object store, a database, a git repository): a `SkillFiles` adapter. See [Serve skills from your own storage](references/server.md#serve-skills-from-your-own-storage). +- **Skills that differ per caller or are generated per request:** a `SkillSource`. See [Generate skills with a `SkillSource`](references/server.md#generate-skills-with-a-skillsource). + +**Lay out the folder.** Each folder holding a `SKILL.md` is one skill. Its frontmatter needs a `name` equal to the folder's name and a `description`. Other folders only group skills in the URI. + +**Load the folder once and attach it before the server connects.** `attach` throws on a connected server, and each server takes one skills extension. A server connected by hand keeps its `server.connect(transport)` call, after `attach`. + +```ts source="../../examples/guides/servers/skills.examples.ts#loadSkills_basic" +import { McpServer } from '@modelcontextprotocol/server'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; + +const skills = await loadSkills('./skills'); + +const server = new McpServer({ name: 'team-skills', version: '1.0.0' }); +skills.attach(server); +``` + +**Resolve the folder from the module, not the working directory.** A relative path like `'./skills'` resolves against `process.cwd()`, and MCP hosts may start stdio servers from another directory (such as `/`), where the load throws and takes the whole server down with it. Build the path from the module's location instead: `fileURLToPath(new URL('../skills', import.meta.url))` from `src/index.ts`, or `path.join(__dirname, '../skills')` in CommonJS. + +**Where a factory builds the server, attach inside it**, so every server it builds serves the same catalog. That covers `serveStdio(createServer)` on stdio as well as `createMcpHandler` over HTTP: + +```ts source="../../examples/guides/servers/skills.examples.ts#attach_perRequest" +import { createMcpHandler } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(() => { + const mcp = new McpServer({ name: 'team-skills', version: '1.0.0' }); + skills.attach(mcp); + return mcp; +}); +``` + +**Pick up edits with `reload()`** if the user expects changes to show without a restart. Call it from whatever tells the project the folder changed (a file watcher, an admin route, a deploy hook). A server that stays connected (stdio) notifies its clients itself. Behind `createMcpHandler`, publish the change on the handler, where `changes` is what `await skills.reload()` returned: + +```ts source="../../examples/guides/servers/skills.examples.ts#reload_notify" +if (changes.listChanged) handler.notify.resourcesChanged(); +``` + +Then tell the user about any load warnings, and [verify](#verify). + +Read [references/server.md](references/server.md) for `onWarning`, `strict`, `limits`, `prefix` and `pageSize`. + +## Use skills + +**Wrap the connected client and list.** Listing reads no skill file: each entry carries the skill's frontmatter and file list, enough to show what is on offer. + +```ts source="../../examples/guides/clients/skills.examples.ts#SkillsClient_list" +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; + +const skills = new SkillsClient(client); +const { skills: entries } = await skills.list(); +console.log(entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter.description}`).join('\n')); +``` + +`skills.supported` is `false` for a server that does not declare the extension; a host that connects to several servers checks it per server before listing. `list()` throws an `SdkError` if any entry is invalid, so a host listing servers it does not control creates the client with `new SkillsClient(client, { skipInvalid: true })` and logs the refused entries it returns in `invalid`. + +**Load a skill only after the user (or the host's policy) approves it.** Read its `SKILL.md`, which checks size, digest and frontmatter, and give `manifest.body` to the model: + +```ts source="../../examples/guides/clients/skills.examples.ts#read_manifest" +const git = entries.find(entry => entry.frontmatter.name === 'git-workflow')!; +const manifest = await skills.read(git); +console.log(manifest.verified, manifest.frontmatter); +``` + +Read the files `SKILL.md` refers to with `skills.read(entry, path)` when the model needs them. A failed check throws `SkillVerificationError`: do not use that file, and if it is the `SKILL.md`, do not load the skill. + +Read [references/client.md](references/client.md) for `skipInvalid`, verification reasons, `readDirectory`, `get`, dynamic skills and caching hints. + +## Mistakes to avoid + +Serving: + +- **Writing the protocol by hand.** Serve skills only through `SkillsExtension` (`loadSkills`, `memorySkillFiles`, a `SkillFiles` adapter or a `SkillSource`). Never `setRequestHandler('skills/list', …)`, and never register your own resources under `skill://`: a resource at a skill file's URI takes precedence over the file, and clients then fail to verify it. +- **Missing the skipped skills.** `loadSkills` skips a skill whose `name` differs from its folder, or that contains a symlink, and only warns (on stderr by default) while the others serve. Read the warnings after loading and tell the user which skills were skipped. Pass `strict: true` (in CI, say) to make the load throw instead. +- **Dot-named entries.** Files and folders whose names start with `.` are skipped with no warning at all, and `strict: true` does not catch them. A skill in a `.name` folder, or a dotfile its `SKILL.md` refers to, is simply missing: rename it. +- **A catalog outside the factory's servers.** `serveStdio` and `createMcpHandler` build their servers from a factory, and each server `createMcpHandler` builds lives for one request. Attach inside the factory, and behind `createMcpHandler` call `handler.notify.resourcesChanged()` when `reload()` reports `listChanged`, or connected clients never hear of the change. +- **Expecting edits to show up on their own.** The catalog keeps the digests from load time, so a file edited on disk afterwards is refused with an error until `reload()` runs. +- **`loadSkills` without a file system.** It reads the disk (`ext/skills/node`). On Workers and similar runtimes, use `memorySkillFiles` or a `SkillFiles` adapter from `@modelcontextprotocol/server/ext/skills`. + +Loading: + +- **Raw `resources/read`.** Never fetch skill files with `client.readResource()`: it skips the size, digest and frontmatter checks. Use `skills.read()`. +- **Reading while listing.** Read a skill's files when it is loaded, never while listing. +- **Skipping the host's duties.** Accept skills up to `SKILL_LIMITS` (512 files, 16 MiB) and explain why you decline one, get approval before loading a skill from a server, and record which server each skill came from, so the instructions given to the model can be traced to their source. + +Both: + +- **Invented APIs.** Take skills APIs only from `@modelcontextprotocol/server/ext/skills`, `@modelcontextprotocol/server/ext/skills/node` and `@modelcontextprotocol/client/ext/skills`, using names shown here or in the references. If you need something else, read the package's type declarations instead of guessing. + +## Verify + +**Server:** list every skill and read every file it lists through a real client. In a script or test, connect a `Client` to the server over an in-memory pair and read each file with `SkillsClient`. `read` throws when a file fails a check, and the loop throws when the server lists no skills: + +```ts source="../../examples/guides/servers/skills.examples.ts#verify_inMemory" +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +const client = new Client({ name: 'skills-check', version: '1.0.0' }); +await client.connect(clientTransport); + +const skillsClient = new SkillsClient(client); +const { skills: listed } = await skillsClient.list(); +if (listed.length === 0) throw new Error('the server listed no skills'); +for (const entry of listed) { + const files = entry.resources === 'dynamic' ? [entry.uri] : entry.resources.map(file => file.uri); + for (const uri of files) await skillsClient.read(entry, uri); +} +console.log(`${listed.length} skill(s) listed and verified`); +``` + +An entry whose `resources` is `"dynamic"` (only a `SkillSource` serves one) has no file list, so the loop reads only its `SKILL.md`: `read` checks the frontmatter but has no size or digest to check, and returns `verified: false`. + +For a stdio server, the same loop works over `StdioClientTransport` (from `@modelcontextprotocol/client/stdio`) against the server's real start command; set its `cwd` option to another directory (such as `/`), as hosts do, so a path that depends on the working directory fails here and not in the user's host. Compare the number of skills listed with the number of skill folders, and account for each one missing: a warning, or a dot-name. + +**Client:** run the host against a server that serves at least one skill (the server path above takes a few lines, or `memorySkillFiles` in a test). Check that it lists the skill, asks before loading it, and reads its `SKILL.md` with `verified: true`. + +## References + +| Reference file | Read it when | +| ------------------------------------------------------- | --------------------------------------------- | +| [Serve skills from an MCP server](references/server.md) | The project serves skills from an MCP server | +| [Use skills from MCP servers](references/client.md) | The project loads skills from its MCP servers | diff --git a/skills/mcp-typescript-sdk-skills/references/client.md b/skills/mcp-typescript-sdk-skills/references/client.md new file mode 100644 index 0000000000..069eb270f0 --- /dev/null +++ b/skills/mcp-typescript-sdk-skills/references/client.md @@ -0,0 +1,153 @@ +# Use skills from MCP servers + + + +`SkillsClient` lists a server's skills and reads their files, checking each file against what the server published. + +```ts source="../../../examples/guides/clients/skills.examples.ts#SkillsClient_list" +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; + +const skills = new SkillsClient(client); +const { skills: entries } = await skills.list(); +console.log(entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter.description}`).join('\n')); +``` + +Against a server with two skills, it prints each skill's name and description: + +```text +refunds: Process customer refunds. +git-workflow: Branching and review conventions. +``` + +Listing reads no skill file: each **entry** carries the skill's frontmatter and its file list with digests, enough to decide which skill to load. + +## Handle refused entries + +Create the client with `skipInvalid: true` to keep the valid skills and log the refused entries. + +```ts source="../../../examples/guides/clients/skills.examples.ts#list_invalid" +const tolerant = new SkillsClient(client, { skipInvalid: true }); +const { invalid } = await tolerant.list(); +for (const refused of invalid) console.error(`skills: refused entry #${refused.index} ${refused.uri ?? ''}: ${refused.reason}`); +``` + +By default `list()` throws an `SdkError` naming every entry that fails validation, and every copy of a repeated URI. With `skipInvalid: true` it returns the valid skills and puts those entries in `invalid`, each with `index`, `uri` when it has one, and `reason`. Against this server it is empty, so the loop prints nothing. + +## Load a skill + +Read the entry's `SKILL.md`. + +```ts source="../../../examples/guides/clients/skills.examples.ts#read_manifest" +const git = entries.find(entry => entry.frontmatter.name === 'git-workflow')!; +const manifest = await skills.read(git); +console.log(manifest.verified, manifest.frontmatter); +``` + +The file passes every check, and its frontmatter matches the entry: + +```text +true { + name: 'git-workflow', + description: 'Branching and review conventions.' +} +``` + +`read(entry)` checks the size and SHA-256 digest against the entry, then compares the frontmatter field by field. `manifest.body` holds the instructions to give the model. + +The comparison parses the frontmatter with YAML 1.2 core-schema typing, so `yes` is the string `"yes"`, not `true`. If a server builds its entries with a parser that types values differently, pass a matching YAML parser as `parseFrontmatter` to `new SkillsClient`; it receives the YAML between the `---` lines. + +## Read the files a skill references + +Pass the path `SKILL.md` uses. + +```ts source="../../../examples/guides/clients/skills.examples.ts#read_reference" +const review = await skills.read(git, 'references/REVIEW.md'); +console.log(review.text); +``` + +`review.text` holds the verified file: + +```text +# Review checklist + +- Tests pass. + +``` + +A relative path resolves against the skill's folder, as a link in `SKILL.md` does, and `read` ignores a fragment. It matches the path to the entry's file list however either side percent-encodes the name, so `references/Q&A.md` works. `bytes` always holds the file's bytes; `text` holds the file's text when the server sent it as text, and always for the skill's own `SKILL.md`. + +## Handle verification failures + +Catch `SkillVerificationError`. + +```ts source="../../../examples/guides/clients/skills.examples.ts#SkillVerificationError" +import { SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; + +try { + await skills.read(git, 'references/MISSING.md'); +} catch (error) { + if (!(error instanceof SkillVerificationError)) throw error; + console.log(error.reason, error.uri); +} +``` + +The entry does not list `references/MISSING.md`, so `read` refuses it before sending any request: + +```text +unlisted skill://git-workflow/references/MISSING.md +``` + +`reason` is `unlisted`, `size`, `digest` or `frontmatter`; a path that does not parse is `unlisted` too. When a size, digest or frontmatter check fails on a copy the client served from its response cache, `read` fetches the file once more with `cacheMode: 'refresh'` and throws only if the fresh copy fails as well; a copy that came from the server fails at once. On any reason, do not use the file; for `SKILL.md`, do not load the skill. + +## Browse a skill folder + +List a folder's direct children when the server declares `directoryRead`. + +```ts source="../../../examples/guides/clients/skills.examples.ts#readDirectory" +if (skills.directoryRead) { + const { resources: children } = await skills.readDirectory('skill://acme/billing/refunds'); + console.log(children.map(child => `${child.name} ${child.mimeType}`).join('\n')); +} +``` + +The `refunds` folder holds its `SKILL.md` and a `templates` folder: + +```text +SKILL.md text/markdown +templates inode/directory +``` + +## Get a skill by URI + +Fetch one entry, listed or not. + +```ts source="../../../examples/guides/clients/skills.examples.ts#get" +const { skill: refunds } = await skills.get('skill://acme/billing/refunds/SKILL.md'); +console.log(refunds.frontmatter.name); +``` + +It prints the skill's name from the entry's frontmatter: + +```text +refunds +``` + +On 2026-07-28, `list()`, `get()` and `readDirectory()` also return the server's caching hints, `ttlMs` and `cacheScope`, and throw an `SdkError` when a response leaves either out. + +::: info Dynamic skills +An entry whose `resources` is `"dynamic"` has no file list. `read` then skips the size and digest checks and returns `verified: false`, but still compares the `SKILL.md` frontmatter. +::: + +::: warning Host responsibilities +Accept skills up to `SKILL_LIMITS` (512 files, 16 MiB) and explain why you decline one, get approval before you load a skill from a server, and record which server each skill came from. Read a skill's files when you load it, never while listing. +::: + +## Recap + +- `new SkillsClient(client)` wraps a connected client; `list()` returns entries without reading files. +- `list()` throws on an invalid entry; `skipInvalid: true` keeps the valid skills and returns the refused ones in `invalid`. +- `read(entry)` verifies `SKILL.md` (size, digest, frontmatter) and returns its frontmatter and body. +- `read(entry, path)` resolves a path against the skill's folder and verifies that file. +- A failed check throws `SkillVerificationError` with a `reason`. + + diff --git a/skills/mcp-typescript-sdk-skills/references/server.md b/skills/mcp-typescript-sdk-skills/references/server.md new file mode 100644 index 0000000000..e818b5c62f --- /dev/null +++ b/skills/mcp-typescript-sdk-skills/references/server.md @@ -0,0 +1,196 @@ +# Serve skills from an MCP server + + + +`loadSkills` reads a folder of skills once; `attach` serves it on a server. + +```ts source="../../../examples/guides/servers/skills.examples.ts#loadSkills_basic" +import { McpServer } from '@modelcontextprotocol/server'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; + +const skills = await loadSkills('./skills'); + +const server = new McpServer({ name: 'team-skills', version: '1.0.0' }); +skills.attach(server); +``` + +A **skill** is a folder with a `SKILL.md` and the files it references (SEP-2640). Clients list skills with `skills/list` and read each file with `resources/read`, checking it against the digest your server published. + +## Lay out the skills folder + +Each folder that contains a `SKILL.md` is one skill; other folders only group skills in the URI. + +```text +skills/ +├── git-workflow/ +│ ├── SKILL.md +│ └── references/REVIEW.md +└── acme/billing/refunds/ + └── SKILL.md +``` + +`SKILL.md` starts with YAML frontmatter holding `name` (the same as the skill's folder name) and `description`. Print the URIs the catalog publishes: + +```ts source="../../../examples/guides/servers/skills.examples.ts#entries" +console.log(skills.entries.map(skill => skill.uri).join('\n')); +``` + +It prints one `SKILL.md` URI per skill, sorted: + +```text +skill://acme/billing/refunds/SKILL.md +skill://git-workflow/SKILL.md +``` + +## Serve one catalog from every server + +Attach the same catalog to each server your handler creates. + +```ts source="../../../examples/guides/servers/skills.examples.ts#attach_perRequest" +import { createMcpHandler } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(() => { + const mcp = new McpServer({ name: 'team-skills', version: '1.0.0' }); + skills.attach(mcp); + return mcp; +}); +``` + +The catalog keeps metadata only (paths, digests, sizes, frontmatter); each read goes to the folder, and the SDK serves the bytes only if they still match the published digest. It sends a file as `text` whenever its bytes are valid UTF-8 (a BOM included), whatever its MIME type, and as a base64 `blob` otherwise. + +## Pick up changes with `reload()` + +Rescan the folder and swap the new catalog in at once. + +```ts source="../../../examples/guides/servers/skills.examples.ts#reload" +const changes = await skills.reload(); +console.log(changes); +``` + +With nothing changed on disk, `reload()` reports no changes: + +```text +{ added: [], removed: [], changed: [], listChanged: false } +``` + +Until you call `reload()`, the server answers a read of a file edited on disk with an error instead of unverified content. If the rescan fails, `reload()` rejects and the old catalog keeps serving. + +`listChanged` is `true` when a skill appears, disappears, or changes its name or description. A server that stays connected (on stdio, or in a stateful 2025-11-25 HTTP session) then sends its clients `notifications/resources/list_changed` itself. A server that `createMcpHandler` builds lives for one request, so publish the change on the handler instead: + +```ts source="../../../examples/guides/servers/skills.examples.ts#reload_notify" +if (changes.listChanged) handler.notify.resourcesChanged(); +``` + +Every open `subscriptions/listen` stream that opted in to resource list changes receives the notification. + +## Serve skills defined in code + +Pass the files to `memorySkillFiles` where there is no file system, such as Workers or tests. + +```ts source="../../../examples/guides/servers/skills.examples.ts#memorySkillFiles" +import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; + +const RELEASE_NOTES_MD = `--- +name: release-notes +description: Write release notes from merged pull requests. +--- +# Release notes +`; + +const inline = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': RELEASE_NOTES_MD })); +``` + +`memorySkillFiles` re-reads the object you pass whenever the catalog rescans (at load and on `reload()`), so `reload()` sees files you add or change later. + +## Serve skills from your own storage + +Implement `SkillFiles`, two functions over relative paths, to load from any store. + +```ts source="../../../examples/guides/servers/skills.examples.ts#skillFiles_custom" +import type { SkillFiles } from '@modelcontextprotocol/server/ext/skills'; + +// Two operations over '/'-separated paths, '' being the root: here, a key-value store of objects. +function bucketSkillFiles(bucket: ReadonlyMap): SkillFiles { + return { + async list(dir) { + const prefix = dir === '' ? '' : `${dir}/`; + const children = new Map(); + for (const key of bucket.keys()) { + if (!key.startsWith(prefix)) continue; + const [name, ...rest] = key.slice(prefix.length).split('/'); + if (name !== undefined) children.set(name, rest.length > 0 ? 'directory' : 'file'); + } + return [...children].map(([name, kind]) => ({ name, kind })); + }, + async read(key) { + const bytes = bucket.get(key); + if (bytes === undefined) throw new Error(`no object ${key}`); + return bytes; + } + }; +} + +const fromBucket = await SkillsExtension.load( + bucketSkillFiles(new Map([['release-notes/SKILL.md', new TextEncoder().encode(RELEASE_NOTES_MD)]])) +); +``` + +The catalog logic (discovery, digests, rule checks, paging, verified reads) is the same for every adapter. Report a symlink or other special file as `kind: 'unsupported'`; the catalog skips the skill that contains one. + +## Generate skills with a `SkillSource` + +Build entries with `skillEntry` and serve them from a `SkillSource` when the catalog depends on the request. + +```ts source="../../../examples/guides/servers/skills.examples.ts#skillSource" +import type { SkillSource } from '@modelcontextprotocol/server/ext/skills'; +import { skillEntry } from '@modelcontextprotocol/server/ext/skills'; + +const ON_CALL_MD = `--- +name: on-call +description: Handle a page while on call. +--- +# On call +`; +const onCall = await skillEntry('on-call', { 'SKILL.md': ON_CALL_MD }); + +const source: SkillSource = { + list: async () => ({ skills: [onCall] }), + get: async uri => (uri === onCall.uri ? onCall : undefined), + read: async uri => (uri === onCall.uri ? { text: ON_CALL_MD, mimeType: 'text/markdown' } : undefined) +}; +const generated = new SkillsExtension({ source }); +``` + +Every source method receives the request context (`ctx`), so you can list different skills per caller. Add `readDirectory` to serve folder listings, and `listResources` to list skills in `resources/list`. + +## Handle invalid skills + +Route warnings where you want them; by default they go to stderr. + +```ts source="../../../examples/guides/servers/skills.examples.ts#onWarning" +const checked = await loadSkills('./skills', { + onWarning: ({ path, message }) => console.error(`skills: ${path}: ${message}`) +}); +``` + +`loadSkills` skips, with a warning, any skill that breaks the rules, such as a `name` that differs from its folder or a symlinked file; the other skills still serve. Skills over 512 files or 16 MiB still publish, with a warning; `limits` changes those thresholds. The same options take `prefix` (`'acme'` serves every skill under `skill://acme/…`) and `pageSize` (entries per `skills/list` and directory page, default 100). + +Set `strict: true` to make `loadSkills` throw a `SkillCatalogError` listing every problem instead. Under `strict: true`, a symlink or other special file anywhere in the folder fails the load, even one outside every skill; hidden entries (names starting with `.`) are the exception, since the scan skips them entirely. + +::: warning +Do not register your own resources under `skill://`. A resource you register at a skill file's URI takes precedence over that file, and clients then fail to verify it. +::: + +::: info +Skills work on both protocol versions; the `cacheHint` option applies on 2026-07-28 only — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). +::: + +## Recap + +- `loadSkills('./skills')` builds a catalog once; `attach(server)` serves it on any number of servers. +- Each folder with a `SKILL.md` is a skill; other folders group skills in the URI. +- `reload()` swaps in a rescanned catalog all at once; behind `createMcpHandler`, call `handler.notify.resourcesChanged()` when it reports `listChanged`. +- `memorySkillFiles`, a `SkillFiles` adapter or a `SkillSource` serve skills without a folder. +- `loadSkills` skips invalid skills with a warning; `strict: true` makes it throw instead. + +