Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 19 additions & 9 deletions docs/.vitepress/skill.ts
Original file line number Diff line number Diff line change
@@ -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 `<!--@include: -->` 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. */
Comment thread
claude[bot] marked this conversation as resolved.
function skillFile(path: string): string {
return /^(?:\.\.\/)?SKILL\.md$/.test(path) ? 'SKILL.md' : path.replace(/^references\//, '');
}

/** The docs pages assembled with `<!--@include: -->` from a skill in `skills/`, each with the skill files it includes. */
const SKILL_PAGES: ReadonlyMap<string, ReadonlySet<string>> = 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(/<!--@include: \.\.\/\.\.\/skills\/[^/]+\/(SKILL\.md|references\/[^#]+?)(?:#[\w-]+)?-->/g);
Comment thread
claude[bot] marked this conversation as resolved.
return [page, new Set([...includes].map(([, path]) => skillFile(path!)))];
})
);

/**
* GitHub's heading slug (github-slugger) for a heading's markdown text — the
Expand All @@ -21,16 +31,16 @@ 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);
if (path.startsWith(`${site}/`)) {
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;
}
4 changes: 2 additions & 2 deletions docs/_meta/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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#<region>"
```

The sync script scans `docs/**/*.md`.
The sync script scans `docs/**/*.md` and `skills/**/*.md`.

### 4. How example files are typechecked

Expand Down Expand Up @@ -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):
Expand Down
148 changes: 6 additions & 142 deletions docs/clients/skills.md
Original file line number Diff line number Diff line change
@@ -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.
<!--@include: ../../skills/mcp-typescript-sdk-skills/references/client.md#guide-->

```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`.
Loading
Loading