diff --git a/.changeset/skills-extension.md b/.changeset/skills-extension.md new file mode 100644 index 0000000000..01bede1d7e --- /dev/null +++ b/.changeset/skills-extension.md @@ -0,0 +1,18 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +author: @KKonstantinov +author: @tobi-oye +author: @mattzcarey + +Add the Skills extension (SEP-2640), reachable through new `ext/skills` subpaths; package root exports are unchanged. + +- `@modelcontextprotocol/server/ext/skills`: `SkillsExtension` (`attach(server)` serves `skills/list`, `skills/get`, `resources/directory/read` and a `skill://{+path}` resource template), `SkillsExtension.load(files)` returning a `SkillCatalog` with `entries` and `reload()`, the `SkillFiles` storage adapter with `memorySkillFiles`, `SkillSource` for generated catalogs, `skillEntry`, and `SkillCatalogError`. Invalid skills are skipped with a warning (`onWarning`), or fail under `strict`. A file whose bytes are valid UTF-8 is served as `text`, whatever its MIME type; any other file as a base64 `blob`. +- `@modelcontextprotocol/server/ext/skills/node`: `loadSkills(folder)` and `nodeSkillFiles(folder)`. +- `@modelcontextprotocol/client/ext/skills`: `SkillsClient` with `list()` (throws on an invalid entry; `skipInvalid: true` keeps the valid ones and returns the rest in `invalid`), `get()`, `read(skill, path?)` (size, digest and `SKILL.md` frontmatter verified, `SkillVerificationError` on a mismatch; a copy from the response cache that fails a check is fetched once more with `cacheMode: 'refresh'` first) and `readDirectory()`. +- `@modelcontextprotocol/core/ext/skills`: schemas, types and constants, including `SKILL_LIMITS`. + +Works on 2025-11-25 and 2026-07-28; caching fields (`ttlMs`, `cacheScope`) are sent on 2026-07-28 only. diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index bb2dbef504..989595cbf5 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -30,6 +30,7 @@ jobs: - run: pnpm install - run: pnpm run build:all - run: pnpm run test:conformance:client:all + - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:skills:2026 - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:2026 server-conformance: @@ -50,4 +51,5 @@ jobs: - run: pnpm run test:conformance:server - run: pnpm run test:conformance:server:draft - run: pnpm run test:conformance:server:extensions + - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:skills:2025 - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:2026 diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 02ab4d2ff6..9c07c8c0ed 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -21,6 +21,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ items: [ { text: 'Tools', link: '/servers/tools' }, { text: 'Resources', link: '/servers/resources' }, + { text: 'Skills', link: '/servers/skills' }, { text: 'Prompts', link: '/servers/prompts' }, { text: 'Completion', link: '/servers/completion' }, { text: 'Logging, progress, cancellation', link: '/servers/logging-progress-cancellation' }, @@ -56,7 +57,8 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'OAuth', link: '/clients/oauth' }, { text: 'Machine auth', link: '/clients/machine-auth' }, { text: 'Middleware', link: '/clients/middleware' }, - { text: 'Caching', link: '/clients/caching' } + { text: 'Caching', link: '/clients/caching' }, + { text: 'Skills', link: '/clients/skills' } ] }, { text: 'Protocol versions', link: '/protocol-versions' }, diff --git a/docs/clients/skills.md b/docs/clients/skills.md new file mode 100644 index 0000000000..bf6b87352b --- /dev/null +++ b/docs/clients/skills.md @@ -0,0 +1,151 @@ +--- +shape: how-to +--- + +# 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'; + +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 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 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 +``` + +::: 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 new file mode 100644 index 0000000000..b3388d42c9 --- /dev/null +++ b/docs/servers/skills.md @@ -0,0 +1,196 @@ +--- +shape: how-to +--- + +# 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'; + +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](../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/README.md b/examples/README.md index fc57f2de7d..51bc960a13 100644 --- a/examples/README.md +++ b/examples/README.md @@ -45,6 +45,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] | [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern | | [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | | [`extension-capabilities/`](./extension-capabilities/README.md) | Declaring `capabilities.extensions` and reading the negotiated map | stdio + http | dual | +| [`skills/`](./skills/README.md) | Skills over MCP (SEP-2640): `loadSkills` a folder, `SkillsClient` lists, loads and verifies | stdio + http | dual | | [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | | [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | | [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | diff --git a/examples/client-quickstart/tsconfig.json b/examples/client-quickstart/tsconfig.json index b9650ed2f8..e227905f23 100644 --- a/examples/client-quickstart/tsconfig.json +++ b/examples/client-quickstart/tsconfig.json @@ -16,8 +16,14 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ] } }, diff --git a/examples/guides/clients/skills.examples.ts b/examples/guides/clients/skills.examples.ts new file mode 100644 index 0000000000..50f55223ef --- /dev/null +++ b/examples/guides/clients/skills.examples.ts @@ -0,0 +1,133 @@ +/** + * Companion example for `docs/clients/skills.md`. + * + * Every `ts` fence on that page 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. + * + * npx tsx guides/clients/skills.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; +import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; + +const catalog = await SkillsExtension.load( + memorySkillFiles({ + 'git-workflow/SKILL.md': `--- +name: git-workflow +description: Branching and review conventions. +--- +# Git workflow + +Go through references/REVIEW.md. +`, + 'git-workflow/references/REVIEW.md': `# Review checklist + +- Tests pass. +`, + 'acme/billing/refunds/SKILL.md': `--- +name: refunds +description: Process customer refunds. +--- +# Refunds +`, + 'acme/billing/refunds/templates/refund-email.md': 'Your refund is on its way.\n' + }) +); +const server = new McpServer({ name: 'team-skills', version: '1.0.0' }); +catalog.attach(server); +const client = new Client({ name: 'skills-docs-host', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +//#region 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')); +//#endregion SkillsClient_list + +//#region 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}`); +//#endregion list_invalid + +//#region read_manifest +const git = entries.find(entry => entry.frontmatter.name === 'git-workflow')!; +const manifest = await skills.read(git); +console.log(manifest.verified, manifest.frontmatter); +//#endregion read_manifest + +//#region read_reference +const review = await skills.read(git, 'references/REVIEW.md'); +console.log(review.text); +//#endregion read_reference + +//#region 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); +} +//#endregion SkillVerificationError + +//#region readDirectory +if (skills.directoryRead) { + const children = await skills.readDirectory('skill://acme/billing/refunds'); + console.log(children.map(child => `${child.name} ${child.mimeType}`).join('\n')); +} +//#endregion readDirectory + +//#region get +const refunds = await skills.get('skill://acme/billing/refunds/SKILL.md'); +console.log(refunds.frontmatter.name); +//#endregion get + +// Claims the page makes, checked on every run. +const claim = (ok: boolean, what: string): void => { + if (!ok) throw new Error(`clients/skills.md: ${what}`); +}; +const listed = entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter.description}`); +claim( + JSON.stringify(listed) === JSON.stringify(['refunds: Process customer refunds.', 'git-workflow: Branching and review conventions.']), + 'list() must return the quoted entries, in order' +); +claim(invalid.length === 0, 'invalid must be empty against this server'); +claim( + manifest.verified && + manifest.body !== undefined && + JSON.stringify(manifest.frontmatter) === JSON.stringify({ name: 'git-workflow', description: 'Branching and review conventions.' }), + 'read(entry) must verify SKILL.md and return its frontmatter and body' +); +claim(review.text === '# Review checklist\n\n- Tests pass.\n', 'read(entry, path) must return the quoted file text'); +let missing: unknown; +try { + await skills.read(git, 'references/MISSING.md'); +} catch (error) { + missing = error; +} +claim( + missing instanceof SkillVerificationError && + missing.reason === 'unlisted' && + missing.uri === 'skill://git-workflow/references/MISSING.md', + 'an unlisted path must throw SkillVerificationError(unlisted) with the quoted URI' +); +claim(skills.directoryRead, 'the server must declare directoryRead'); +const refundsFolder = await skills.readDirectory('skill://acme/billing/refunds'); +claim( + JSON.stringify(refundsFolder.map(child => `${child.name} ${child.mimeType}`)) === + JSON.stringify(['SKILL.md text/markdown', 'templates inode/directory']), + 'the refunds folder must list the quoted children' +); +claim(refunds.frontmatter.name === 'refunds', 'get() must return the refunds entry'); +await client.close(); +await server.close(); diff --git a/examples/guides/servers/skills.examples.ts b/examples/guides/servers/skills.examples.ts new file mode 100644 index 0000000000..ecef49c604 --- /dev/null +++ b/examples/guides/servers/skills.examples.ts @@ -0,0 +1,148 @@ +/** + * Companion example for `docs/servers/skills.md`. + * + * Every `ts` fence on that page 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. + * + * npx tsx guides/servers/skills.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The page loads `./skills`; run from this file's folder so that path resolves. +process.chdir(path.dirname(fileURLToPath(import.meta.url))); + +//#region 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); +//#endregion loadSkills_basic + +//#region entries +console.log(skills.entries.map(skill => skill.uri).join('\n')); +//#endregion entries + +//#region 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; +}); +//#endregion attach_perRequest + +// What the handler publishes to `subscriptions/listen` streams, recorded for the checks at the end. +const published: string[] = []; +handler.bus.subscribe(event => published.push(event.kind)); + +//#region reload +const changes = await skills.reload(); +console.log(changes); +//#endregion reload + +//#region reload_notify +if (changes.listChanged) handler.notify.resourcesChanged(); +//#endregion reload_notify + +//#region 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 })); +//#endregion memorySkillFiles + +//#region 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)]])) +); +//#endregion skillFiles_custom + +//#region 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 }); +//#endregion skillSource + +//#region onWarning +const checked = await loadSkills('./skills', { + onWarning: ({ path, message }) => console.error(`skills: ${path}: ${message}`) +}); +//#endregion onWarning + +// 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 (JSON.stringify(changes) !== JSON.stringify({ added: [], removed: [], changed: [], listChanged: false })) { + throw new Error('skills.md: reload claim failed'); +} +// reload_notify: nothing changed, so nothing was published; a skill appearing is a listing change the handler publishes. +if (published.length > 0) throw new Error('skills.md: an unchanged reload must not publish'); +const record: Record = { 'release-notes/SKILL.md': RELEASE_NOTES_MD }; +const live = await SkillsExtension.load(memorySkillFiles(record)); +record['on-call/SKILL.md'] = ON_CALL_MD; +const liveChanges = await live.reload(); +if (liveChanges.listChanged) handler.notify.resourcesChanged(); +if (!liveChanges.listChanged || JSON.stringify(published) !== JSON.stringify(['resources_list_changed'])) { + throw new Error('skills.md: a skill added must set listChanged, and handler.notify must publish resources/list_changed'); +} +if (inline.entries[0]?.frontmatter.name !== 'release-notes' || fromBucket.entries[0]?.uri !== inline.entries[0]?.uri) { + throw new Error('skills.md: in-code and adapter catalogs must match'); +} +for (const extension of [inline, fromBucket, generated]) extension.attach(new McpServer({ name: 'docs-check', version: '1.0.0' })); +await handler.close(); +await server.close(); diff --git a/examples/guides/servers/skills/acme/billing/refunds/SKILL.md b/examples/guides/servers/skills/acme/billing/refunds/SKILL.md new file mode 100644 index 0000000000..e57debb7b0 --- /dev/null +++ b/examples/guides/servers/skills/acme/billing/refunds/SKILL.md @@ -0,0 +1,6 @@ +--- +name: refunds +description: Process customer refunds. +--- + +# Refunds diff --git a/examples/guides/servers/skills/git-workflow/SKILL.md b/examples/guides/servers/skills/git-workflow/SKILL.md new file mode 100644 index 0000000000..a6ddd6893b --- /dev/null +++ b/examples/guides/servers/skills/git-workflow/SKILL.md @@ -0,0 +1,8 @@ +--- +name: git-workflow +description: Branching and review conventions. +--- + +# Git workflow + +Before opening a pull request, go through [the review checklist](references/REVIEW.md). diff --git a/examples/guides/servers/skills/git-workflow/references/REVIEW.md b/examples/guides/servers/skills/git-workflow/references/REVIEW.md new file mode 100644 index 0000000000..7c48146e18 --- /dev/null +++ b/examples/guides/servers/skills/git-workflow/references/REVIEW.md @@ -0,0 +1,3 @@ +# Review checklist + +- Tests pass. diff --git a/examples/server-quickstart/tsconfig.json b/examples/server-quickstart/tsconfig.json index 8866f024a3..ad7cceae55 100644 --- a/examples/server-quickstart/tsconfig.json +++ b/examples/server-quickstart/tsconfig.json @@ -16,8 +16,14 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ] } }, diff --git a/examples/shared/tsconfig.json b/examples/shared/tsconfig.json index 59ce27deee..75cedeee6c 100644 --- a/examples/shared/tsconfig.json +++ b/examples/shared/tsconfig.json @@ -16,9 +16,15 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], diff --git a/examples/skills/README.md b/examples/skills/README.md new file mode 100644 index 0000000000..90764a41e5 --- /dev/null +++ b/examples/skills/README.md @@ -0,0 +1,16 @@ +# skills + +The server serves the `skills/` folder as MCP skills (SEP-2640) with one +`loadSkills` call, under the `examples` prefix. The client lists the catalog, +loads `git-workflow` (its `SKILL.md`, verified for size, digest and +frontmatter), follows the reference to `references/REVIEW.md`, browses the +`refunds` folder, and catches a verification failure. + +```bash +pnpm --filter @mcp-examples/skills client # modern leg over stdio +pnpm --filter @mcp-examples/skills client -- --legacy +pnpm --filter @mcp-examples/skills run server -- --http --port 3000 +pnpm --filter @mcp-examples/skills client -- --http http://127.0.0.1:3000/mcp +``` + +See [Serving skills](../../docs/servers/skills.md) and [Reading skills](../../docs/clients/skills.md). diff --git a/examples/skills/client.ts b/examples/skills/client.ts new file mode 100644 index 0000000000..63643ce047 --- /dev/null +++ b/examples/skills/client.ts @@ -0,0 +1,68 @@ +/** + * Connects to `./server.ts` and walks the host side of MCP skills: list the + * catalog, load a skill (its SKILL.md, verified), follow a reference, browse + * a skill folder, and catch a verification failure. + * + * Spawns the sibling `server.ts` over stdio by default, or connects to a + * running endpoint under `--http `. See `examples/CONTRIBUTING.md` for + * the canonical shape. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient, SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'skills-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await client.connect( + transport === 'stdio' + ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) + : new StreamableHTTPClientTransport(new URL(url)) +); + +const skills = new SkillsClient(client); +check.ok(skills.supported); + +// 1. List: entries only (frontmatter, file list, digests). No skill file is read yet. +const { skills: entries, invalid } = await skills.list(); +console.log(`[client] ${era} leg: ${entries.map(entry => entry.frontmatter.name).join(', ')}`); +check.deepEqual(invalid, []); +check.deepEqual( + entries.map(entry => entry.uri), + [ + 'skill://examples/acme/billing/refunds/SKILL.md', + 'skill://examples/acme/billing/refunds/escalation/SKILL.md', + 'skill://examples/git-workflow/SKILL.md' + ] +); + +// 2. Load a skill: SKILL.md is checked for size, digest and frontmatter before you use it. +const git = await skills.get('skill://examples/git-workflow/SKILL.md'); +const manifest = await skills.read(git); +check.equal(manifest.verified, true); +check.equal(manifest.frontmatter?.['name'], 'git-workflow'); +check.match(manifest.body ?? '', /review checklist/); + +// 3. Follow a reference: the relative path resolves against the skill folder and is verified too. +const review = await skills.read(git, 'references/REVIEW.md'); +check.match(review.text ?? '', /# Review checklist/); + +// 4. Browse a skill folder (the server declares directoryRead). +check.ok(skills.directoryRead); +const children = await skills.readDirectory('skill://examples/acme/billing/refunds'); +check.deepEqual( + children.map(child => child.name), + ['SKILL.md', 'escalation', 'templates'] +); + +// 5. A failed check is an error, never content: here, an entry whose frontmatter was altered. +const altered = { ...git, frontmatter: { ...git.frontmatter, description: 'Something else.' } }; +await check.rejects(skills.read(altered), (error: unknown) => error instanceof SkillVerificationError && error.reason === 'frontmatter'); + +console.log(`[client] ${era} leg: loaded ${git.frontmatter.name}, followed 1 reference, verification failure caught`); +await client.close(); diff --git a/examples/skills/package.json b/examples/skills/package.json new file mode 100644 index 0000000000..8950b18b42 --- /dev/null +++ b/examples/skills/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/skills", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "Both legs list, load and verify skills, follow a reference and browse a skill folder." + } +} diff --git a/examples/skills/server.ts b/examples/skills/server.ts new file mode 100644 index 0000000000..91d330f214 --- /dev/null +++ b/examples/skills/server.ts @@ -0,0 +1,39 @@ +/** + * Serves the `skills/` folder next to this file as MCP skills (SEP-2640), + * under the `examples` prefix: one `loadSkills` call at startup, attached to + * every server instance. + * + * One binary, either transport — selected by `--http --port ` (defaults to + * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. + */ +import { serve } from '@hono/node-server'; +import { parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +// Load once: the catalog keeps metadata only and serves every server below. +const skills = await loadSkills(siblingPath(import.meta.url, 'skills'), { prefix: 'examples' }); + +function buildServer(): McpServer { + const mcp = new McpServer({ name: 'skills-example', version: '1.0.0' }); + skills.attach(mcp); + return mcp; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + // `createMcpHonoApp()` arms localhost host/origin validation by default; + // bind loopback explicitly to match. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/skills/skills/acme/billing/refunds/SKILL.md b/examples/skills/skills/acme/billing/refunds/SKILL.md new file mode 100644 index 0000000000..65d8c877b3 --- /dev/null +++ b/examples/skills/skills/acme/billing/refunds/SKILL.md @@ -0,0 +1,11 @@ +--- +name: refunds +description: Process a customer refund end to end. +metadata: + owner: billing +--- + +# Refunds + +Check the order, issue the refund, then send [the refund email](templates/refund-email.md). +For disputed orders, use the escalation skill. diff --git a/examples/skills/skills/acme/billing/refunds/escalation/SKILL.md b/examples/skills/skills/acme/billing/refunds/escalation/SKILL.md new file mode 100644 index 0000000000..16eed5361b --- /dev/null +++ b/examples/skills/skills/acme/billing/refunds/escalation/SKILL.md @@ -0,0 +1,8 @@ +--- +name: escalation +description: Escalate a disputed refund to the billing lead. +--- + +# Escalation + +Collect the order number and the customer's reason, then page the billing lead. diff --git a/examples/skills/skills/acme/billing/refunds/templates/refund-email.md b/examples/skills/skills/acme/billing/refunds/templates/refund-email.md new file mode 100644 index 0000000000..c34ecfeefa --- /dev/null +++ b/examples/skills/skills/acme/billing/refunds/templates/refund-email.md @@ -0,0 +1,3 @@ +Hello {{customer}}, + +Your refund of {{amount}} is on its way. diff --git a/examples/skills/skills/git-workflow/SKILL.md b/examples/skills/skills/git-workflow/SKILL.md new file mode 100644 index 0000000000..abbe9dfe82 --- /dev/null +++ b/examples/skills/skills/git-workflow/SKILL.md @@ -0,0 +1,10 @@ +--- +name: git-workflow +description: Branching, commit and review conventions for this team. +--- + +# Git workflow + +1. Branch from `main` as `feature/`. +2. Keep commits small; write messages in the imperative. +3. Before opening a pull request, go through [the review checklist](references/REVIEW.md). diff --git a/examples/skills/skills/git-workflow/references/REVIEW.md b/examples/skills/skills/git-workflow/references/REVIEW.md new file mode 100644 index 0000000000..ab65e3edf0 --- /dev/null +++ b/examples/skills/skills/git-workflow/references/REVIEW.md @@ -0,0 +1,4 @@ +# Review checklist + +- Tests pass locally. +- A changeset describes the change. diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 9405854c1f..28288e5c6d 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -15,6 +15,9 @@ "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills/node": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/node.ts"], "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/fastify": ["./node_modules/@modelcontextprotocol/fastify/src/index.ts"], @@ -24,9 +27,13 @@ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/index.ts" ], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], + "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"] } } diff --git a/packages/client/package.json b/packages/client/package.json index 29bdd4f8db..c9390d3af2 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -40,6 +40,16 @@ "default": "./dist/stdio.cjs" } }, + "./ext/skills": { + "import": { + "types": "./dist/ext/skills/index.d.mts", + "default": "./dist/ext/skills/index.mjs" + }, + "require": { + "types": "./dist/ext/skills/index.d.cts", + "default": "./dist/ext/skills/index.cjs" + } + }, "./validators/ajv": { "import": { "types": "./dist/validators/ajv.d.mts", @@ -107,6 +117,9 @@ "types": "./dist/index.d.mts", "typesVersions": { "*": { + "ext/skills": [ + "dist/ext/skills/index.d.mts" + ], "validators/ajv": [ "dist/validators/ajv.d.mts" ], diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8118b60cea..096a8660e2 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -435,6 +435,22 @@ const LIST_CHANGED_EVICTIONS: Readonly> = { const DEFAULT_LIST_MAX_PAGES = 64; +// Package-internal (not exported from the package): what the `ext/` clients share with Client. Set in Client's static block. +export const clientInternals = {} as { + enforceStrictCapabilities(client: Client): boolean; + walkPages( + client: Client, + method: string, + fetchPage: (cursor: string | undefined) => Promise, + items: (page: R) => unknown[] + ): Promise; + readResource( + client: Client, + params: ReadResourceRequest['params'], + options?: CacheableRequestOptions + ): Promise<{ result: ReadResourceResult; cached: boolean }>; +}; + /** * A handle to an open `subscriptions/listen` stream (protocol revision * 2026-07-28). Change notifications delivered on the stream dispatch to the @@ -572,6 +588,12 @@ export class Client extends Protocol { /** Backing store for {@linkcode getDiscoverResult}. Per-connection. */ private _discoverResult?: DiscoverResult; + static { + clientInternals.enforceStrictCapabilities = client => client._enforceStrictCapabilities; + clientInternals.walkPages = (client, method, fetchPage, items) => client._walkPages(method, fetchPage, items); + clientInternals.readResource = (client, params, options) => client._readResource(params, options); + } + /** * Clears every per-connection field in one place. Called at the start of * each fresh (non-resuming) connect and from `close()`, so a stale @@ -1725,7 +1747,34 @@ export class Client extends Protocol { // when it observes the bump (the result still returns to the caller — // it just is not cached). const generation = this._cache.captureGeneration(method); - const acc = (await this.request({ method, ...(baseParams && { params: { ...baseParams } }) }, options)) as R; + const fetchPage = (cursor: string | undefined): Promise => { + const params = cursor === undefined ? baseParams && { ...baseParams } : { ...baseParams, cursor }; + return this.request({ method, ...(params && { params }) }, options) as Promise; + }; + const acc = await this._walkPages(method, fetchPage, items); + // delete, not `= undefined`: the cache's JSON codec drops + // explicit-undefined properties, so only absence keeps + // `'nextCursor' in result` identical between wire and cache hit. + delete acc.nextCursor; + finalize?.(acc); + if (bypass) return acc; + // The aggregate is ALWAYS written: even when the resolved TTL is ≤0 + // the entry is stored already-stale (mcp.d's `retainForSchema` + // posture) so the `tools/list`-derived index keeps working regardless, + // while the freshness gate in `_serveFromCache` never serves it. + // Page-1 carries the result-level `ttlMs`/`cacheScope` (`acc` IS the + // mutated page-1 object). + await this._cache.write(method, acc, generation, this._freshness(acc)); + return acc; + } + + // Every page's items appended to page 1's: `listMaxPages` caps the walk, a page repeating the one before ends it. + private async _walkPages( + method: string, + fetchPage: (cursor: string | undefined) => Promise, + items: (result: R) => unknown[] + ): Promise { + const acc = await fetchPage(undefined); let cursor = acc.nextCursor; let previous = acc; let pages = 1; @@ -1737,7 +1786,7 @@ export class Client extends Protocol { { method, listMaxPages: this._listMaxPages } ); } - const page = (await this.request({ method, params: { ...baseParams, cursor } }, options)) as R; + const page = await fetchPage(cursor); // Same items and same cursor as the page before: the server made no progress. if (page.nextCursor === cursor && JSON.stringify(items(page)) === JSON.stringify(items(previous))) break; items(acc).push(...items(page)); @@ -1745,19 +1794,6 @@ export class Client extends Protocol { cursor = page.nextCursor; pages++; } - // delete, not `= undefined`: the cache's JSON codec drops - // explicit-undefined properties, so only absence keeps - // `'nextCursor' in result` identical between wire and cache hit. - delete acc.nextCursor; - finalize?.(acc); - if (bypass) return acc; - // The aggregate is ALWAYS written: even when the resolved TTL is ≤0 - // the entry is stored already-stale (mcp.d's `retainForSchema` - // posture) so the `tools/list`-derived index keeps working regardless, - // while the freshness gate in `_serveFromCache` never serves it. - // Page-1 carries the result-level `ttlMs`/`cacheScope` (`acc` IS the - // mutated page-1 object). - await this._cache.write(method, acc, generation, this._freshness(acc)); return acc; } @@ -1882,8 +1918,17 @@ export class Client extends Protocol { * unbounded). */ async readResource(params: ReadResourceRequest['params'], options?: CacheableRequestOptions): Promise { + const { result } = await this._readResource(params, options); + return result; + } + + // `readResource`, and whether the result came from the response cache. + private async _readResource( + params: ReadResourceRequest['params'], + options: CacheableRequestOptions | undefined + ): Promise<{ result: ReadResourceResult; cached: boolean }> { const hit = await this._serveFromCache('resources/read', params.uri, options); - if (hit !== undefined) return hit; + if (hit !== undefined) return { result: hit, cached: true }; // Capture the per-URI eviction generation BEFORE the request: a // `notifications/resources/updated` for this URI arriving while the // read is in flight bumps it (via `evictKey`), and the terminal @@ -1908,7 +1953,7 @@ export class Client extends Protocol { await this._cache.evictKey('resources/read', params.uri); } } - return result; + return { result, cached: false }; } /** Subscribes to change notifications for a resource. The server must support resource subscriptions. */ diff --git a/packages/client/src/ext/skills/entries.ts b/packages/client/src/ext/skills/entries.ts new file mode 100644 index 0000000000..e8cd9fbf2b --- /dev/null +++ b/packages/client/src/ext/skills/entries.ts @@ -0,0 +1,51 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SkillSchema } from '@modelcontextprotocol/core/ext/skills'; +import { canonicalUri, validateSkillEntry } from '@modelcontextprotocol/core-internal/ext/skills'; + +/** A `skills/list` entry the client refused, and why. `index` counts across every page walked. */ +export interface InvalidSkillEntry { + index: number; + uri?: string; + reason: string; +} + +export interface CheckedEntry { + skill?: Skill; + uri?: string; + problems: string[]; +} + +/** Schema and rule checks for one entry received from a server. */ +export function checkEntry(value: unknown): CheckedEntry { + const raw = (value as { uri?: unknown } | null | undefined)?.uri; + const uri = typeof raw === 'string' ? raw : undefined; + const parsed = SkillSchema.safeParse(value); + if (!parsed.success) { + return { uri, problems: parsed.error.issues.map(issue => `${issue.path.join('.') || '(entry)'}: ${issue.message}`) }; + } + return { skill: parsed.data, uri, problems: validateSkillEntry(parsed.data) }; +} + +function keyOf(entry: CheckedEntry): string | undefined { + return entry.uri === undefined ? undefined : (canonicalUri(entry.uri) ?? entry.uri); +} + +/** Splits listing entries into valid skills and refused entries. Every copy of a repeated URI is refused. */ +export function partitionEntries(values: readonly unknown[]): { skills: Skill[]; invalid: InvalidSkillEntry[] } { + const checked = values.map(value => checkEntry(value)); + const counts = new Map(); + for (const entry of checked) { + const key = keyOf(entry); + if (key !== undefined) counts.set(key, (counts.get(key) ?? 0) + 1); + } + const skills: Skill[] = []; + const invalid: InvalidSkillEntry[] = []; + for (const [index, entry] of checked.entries()) { + const key = keyOf(entry); + const repeated = key !== undefined && (counts.get(key) ?? 0) > 1; + const problems = repeated ? [...entry.problems, 'the same uri is listed more than once'] : entry.problems; + if (entry.skill !== undefined && problems.length === 0) skills.push(entry.skill); + else invalid.push({ index, ...(entry.uri !== undefined && { uri: entry.uri }), reason: problems.join('; ') }); + } + return { skills, invalid }; +} diff --git a/packages/client/src/ext/skills/errors.ts b/packages/client/src/ext/skills/errors.ts new file mode 100644 index 0000000000..ea2eedf4d8 --- /dev/null +++ b/packages/client/src/ext/skills/errors.ts @@ -0,0 +1,34 @@ +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal/public'; + +/** Why a skill file failed verification. */ +export type SkillVerificationReason = 'unlisted' | 'size' | 'digest' | 'frontmatter'; + +/** + * A skill file failed verification against its skill entry: not listed, + * wrong size, wrong digest, or (for `SKILL.md`) frontmatter that differs from + * the entry. Hosts must not load the file; for `SKILL.md`, not the skill. + */ +export class SkillVerificationError extends SdkError { + static { + Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SkillVerificationError' }); + } + + readonly reason: SkillVerificationReason; + readonly uri: string; + /** For `frontmatter`: one line per differing field. */ + readonly differences: readonly string[]; + + constructor( + reason: SkillVerificationReason, + uri: string, + message: string, + differences: readonly string[] = [], + options?: ErrorOptions + ) { + super(SdkErrorCode.InvalidResult, message, { reason, uri, differences }, options); + this.name = 'SkillVerificationError'; + this.reason = reason; + this.uri = uri; + this.differences = differences; + } +} diff --git a/packages/client/src/ext/skills/index.ts b/packages/client/src/ext/skills/index.ts new file mode 100644 index 0000000000..515a89c3f0 --- /dev/null +++ b/packages/client/src/ext/skills/index.ts @@ -0,0 +1,31 @@ +// @modelcontextprotocol/client/ext/skills +// +// Host side of the MCP Skills extension (SEP-2640). Adapted, with +// attribution, from the Apache-2.0 `@olaservo/ext-skills` prototype +// (modelcontextprotocol/ext-skills#71), typescript-sdk#2818 and #2972. + +export type { InvalidSkillEntry } from './entries'; +export type { SkillVerificationReason } from './errors'; +export { SkillVerificationError } from './errors'; +export type { SkillFile, SkillListing, SkillsClientOptions } from './skillsClient'; +export { SkillsClient } from './skillsClient'; +export type { + DirectoryResource, + GetSkillResult, + ListSkillsResult, + ReadResourceDirectoryResult, + Skill, + SkillCacheScope, + SkillFrontmatter, + SkillResourceEntry, + SkillResources, + SkillsCapability +} from '@modelcontextprotocol/core/ext/skills'; +export { + DIRECTORY_MIME_TYPE, + RESOURCES_DIRECTORY_READ_METHOD, + SKILL_LIMITS, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD +} from '@modelcontextprotocol/core/ext/skills'; diff --git a/packages/client/src/ext/skills/pageSchemas.ts b/packages/client/src/ext/skills/pageSchemas.ts new file mode 100644 index 0000000000..4ba17e8fdb --- /dev/null +++ b/packages/client/src/ext/skills/pageSchemas.ts @@ -0,0 +1,17 @@ +import * as z from 'zod/v4'; + +/** + * `skills/list` and `skills/get` envelopes that leave entries unchecked, so a + * bad entry is reported on its own instead of failing the whole response. + * Defined with this package's own `zod`, so no schema crosses package copies. + */ +export const SkillsPageSchema = z.looseObject({ + skills: z.array(z.unknown()), + nextCursor: z.string().optional(), + ttlMs: z.number().int().nonnegative().optional(), + cacheScope: z.enum(['public', 'private']).optional() +}); + +export const GetSkillEnvelopeSchema = z.looseObject({ + skill: z.unknown() +}); diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts new file mode 100644 index 0000000000..b30f35cd70 --- /dev/null +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -0,0 +1,323 @@ +import type { DirectoryResource, Skill, SkillCacheScope, SkillResourceEntry } from '@modelcontextprotocol/core/ext/skills'; +import { + ReadResourceDirectoryResultSchema, + RESOURCES_DIRECTORY_READ_METHOD, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD, + skillsCapabilityOf +} from '@modelcontextprotocol/core/ext/skills'; +import { + canonicalUri, + decodeBase64, + decodeUtf8Lossless, + encodeUtf8, + isWithin, + parseSkillMarkdown, + resolveSkillRef, + sha256Digest, + skillFileKey, + skillRootUri, + splitFrontmatter +} from '@modelcontextprotocol/core-internal/ext/skills'; +import type { ReadResourceResult, RequestOptions } from '@modelcontextprotocol/core-internal/public'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal/public'; +import type * as z from 'zod/v4'; + +import type { CacheableRequestOptions, Client } from '../../client/client'; +import { clientInternals } from '../../client/client'; +import type { InvalidSkillEntry } from './entries'; +import { checkEntry, partitionEntries } from './entries'; +import { SkillVerificationError } from './errors'; +import { GetSkillEnvelopeSchema, SkillsPageSchema } from './pageSchemas'; +import { frontmatterDifferences } from './verify'; + +export interface SkillsClientOptions { + /** + * Parses the YAML between a `SKILL.md`'s `---` lines (BOM and CRLF removed) for verification, instead of the + * built-in parser (YAML 1.2 core-schema typing). + */ + parseFrontmatter?: (yaml: string) => Record; + /** Return invalid `skills/list` entries in `invalid` instead of throwing. Default `false`: `list()` throws on any. */ + skipInvalid?: boolean; +} + +/** What `list()` returns: the valid entries, the refused ones (only with `skipInvalid`), and the first page's caching fields. */ +export interface SkillListing { + skills: Skill[]; + invalid: InvalidSkillEntry[]; + /** Only when `list()` was given a cursor (one page); a full walk has none. */ + nextCursor?: string; + ttlMs?: number; + cacheScope?: SkillCacheScope; +} + +/** A skill file as read and checked by {@linkcode SkillsClient.read}. */ +export interface SkillFile { + /** The URI requested: the entry's spelling of the file; for `"dynamic"` skills, the resolved ref in its normalised form. */ + uri: string; + mimeType?: string; + /** Exactly the bytes the entry's digest covers. */ + bytes: Uint8Array; + /** When the server sent text (always, for the skill's own `SKILL.md`). */ + text?: string; + /** Size and digest were checked. `false` only for `"dynamic"` skills. */ + verified: boolean; + /** The skill's own `SKILL.md` only: its parsed frontmatter, equal to the entry's. */ + frontmatter?: Record; + /** The skill's own `SKILL.md` only: the markdown after the frontmatter. */ + body?: string; +} + +function decodeBlob(uri: string, blob: string): Uint8Array { + try { + return decodeBase64(blob); + } catch (error) { + throw new SdkError(SdkErrorCode.InvalidResult, `resources/read returned an invalid base64 blob for ${uri}`, undefined, { + cause: error + }); + } +} + +/** What `read` requests and checks: the file's URI and key, its listing, and whether it is the skill's own `SKILL.md`. */ +interface ReadTarget { + /** The entry's spelling of a listed file; for a `"dynamic"` skill, the URI the ref resolves to. */ + uri: string; + root: string; + /** The file's {@linkcode skillFileKey}: listings and returned contents are matched by it. */ + key: string; + /** `undefined` for `"dynamic"` skills. */ + listed: SkillResourceEntry | undefined; + manifest: boolean; +} + +/** + * Resolves `ref` against the skill root and drops its fragment. A listed file is found in the entry by + * {@linkcode skillFileKey}, since servers percent-encode names differently (`Q&A.md`, `Q%26A.md`); a + * `"dynamic"` skill's file is requested at the resolved URI, as nothing published another spelling. + * Throws `unlisted` for a ref that does not parse or names no file of the skill. + */ +function resolveTarget(skill: Skill, ref: string | undefined): ReadTarget { + const root = skillRootUri(skill.uri); + if (root === undefined) throw new TypeError(`${skill.uri} is not a SKILL.md URI`); + let uri: string; + try { + const resolved = new URL(resolveSkillRef(skill.uri, ref)); + resolved.hash = ''; + uri = resolved.href; + } catch (error) { + const given = ref ?? ''; + throw new SkillVerificationError('unlisted', given, `${given} is not a URI reference`, [], { cause: error }); + } + const key = skillFileKey(root, uri) ?? uri; + const manifest = key === skillFileKey(root, skill.uri); + const notInSkill = () => new SkillVerificationError('unlisted', uri, `${uri} is not a file of the skill ${skill.uri}`); + if (skill.resources === 'dynamic') { + if (!manifest && !isWithin(root, key)) throw notInSkill(); + return { uri, root, key, listed: undefined, manifest }; + } + const listed = skill.resources.find(resource => skillFileKey(root, resource.uri) === key); + if (listed === undefined) throw notInSkill(); + return { uri: listed.uri, root, key, listed, manifest }; +} + +function unsupported(method: string): SdkError { + return new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support the ${SKILLS_EXTENSION_ID} extension (required for ${method})` + ); +} + +/** + * Host side of the MCP Skills extension (SEP-2640): lists, gets and reads a + * server's skills, and verifies every file against the server's entry. + * Approval, origin tagging and loading policy stay with the host. + * + * @example + * ```ts + * const skills = new SkillsClient(client); + * const { skills: entries } = await skills.list(); + * const manifest = await skills.read(entries[0]!); + * ``` + */ +export class SkillsClient { + readonly #client: Client; + readonly #options: SkillsClientOptions; + + constructor(client: Client, options: SkillsClientOptions = {}) { + this.#client = client; + this.#options = options; + } + + /** The server declared the extension (and `resources`, under `enforceStrictCapabilities`). `false` before connect. */ + get supported(): boolean { + const capabilities = this.#client.getServerCapabilities(); + if (skillsCapabilityOf(capabilities) === undefined) return false; + return !clientInternals.enforceStrictCapabilities(this.#client) || capabilities?.resources !== undefined; + } + + /** The server serves `resources/directory/read`. */ + get directoryRead(): boolean { + return this.supported && skillsCapabilityOf(this.#client.getServerCapabilities())?.directoryRead === true; + } + + /** + * Lists the server's skills, walking every page unless given a cursor + * (bounded by the client's `listMaxPages`). Throws an `SdkError` naming + * every invalid entry, and every copy of a repeated URI; with + * `skipInvalid`, returns them in `invalid` instead. On a server without + * the extension: an empty listing. + */ + async list(params: { cursor?: string } = {}, options?: RequestOptions): Promise { + if (!this.supported) { + if (clientInternals.enforceStrictCapabilities(this.#client)) throw unsupported(SKILLS_LIST_METHOD); + // eslint-disable-next-line no-console + console.debug(`SkillsClient.list() called but the server does not declare ${SKILLS_EXTENSION_ID} - returning an empty list`); + return { skills: [], invalid: [] }; + } + const page = await this.#pages(SKILLS_LIST_METHOD, SkillsPageSchema, {}, params.cursor, options, result => result.skills); + const { skills, invalid } = partitionEntries(page.skills); + if (invalid.length > 0 && !this.#options.skipInvalid) { + const detail = invalid.map(entry => `#${entry.index} ${entry.uri ?? ''}: ${entry.reason}`).join('\n'); + throw new SdkError(SdkErrorCode.InvalidResult, `skills/list returned invalid entries:\n${detail}`, { invalid }); + } + return { + skills, + invalid, + ...(params.cursor !== undefined && page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + ...(page.ttlMs !== undefined && { ttlMs: page.ttlMs }), + ...(page.cacheScope !== undefined && { cacheScope: page.cacheScope }) + }; + } + + /** One skill by its `SKILL.md` URI, listed or not. Throws if the server returns an invalid entry or another skill. */ + async get(uri: string, options?: RequestOptions): Promise { + if (!this.supported) throw unsupported(SKILLS_GET_METHOD); + const result = await this.#client.request({ method: SKILLS_GET_METHOD, params: { uri } }, GetSkillEnvelopeSchema, options); + const { skill, problems } = checkEntry(result.skill); + if (skill === undefined || problems.length > 0) { + throw new SdkError(SdkErrorCode.InvalidResult, `skills/get returned an invalid entry for ${uri}: ${problems.join('; ')}`); + } + if (canonicalUri(skill.uri) !== canonicalUri(uri)) { + throw new SdkError(SdkErrorCode.InvalidResult, `skills/get for ${uri} returned ${skill.uri}`); + } + return skill; + } + + /** + * Reads one file of `skill` and verifies it. `ref` is a path relative to + * the skill's root (`references/FORMS.md`) or an absolute URI; the + * default is the skill's own `SKILL.md`; a fragment is ignored. The file + * must be listed in the entry (checked before any request, however either + * side percent-encodes the name) and is requested by the entry's URI; its + * size and digest must match; + * for the skill's own `SKILL.md` the frontmatter must also equal the + * entry's. A copy from the client's response cache that fails a check is + * fetched once more with `cacheMode: 'refresh'`. `"dynamic"` skills skip + * the listing, size and digest checks (`verified: false`) but not the + * frontmatter. Pass entries from {@linkcode SkillsClient.list} or + * {@linkcode SkillsClient.get}; an invalid entry throws a `TypeError` + * before any request. + * + * @throws {SkillVerificationError} when a check fails. + */ + async read(skill: Skill, ref?: string, options?: CacheableRequestOptions): Promise { + if (!this.supported) throw unsupported('resources/read'); + const { problems } = checkEntry(skill); + if (problems.length > 0) throw new TypeError(`${skill.uri} is not a valid skill entry: ${problems.join('; ')}`); + const target = resolveTarget(skill, ref); + const params = { uri: target.uri }; + const { result, cached } = await clientInternals.readResource(this.#client, params, options); + try { + return await this.#verify(skill, target, result); + } catch (error) { + if (!(error instanceof SkillVerificationError) || !cached) throw error; + // A cached copy that fails verification must be fetched again (SEP-2640). + return this.#verify(skill, target, await this.#client.readResource(params, { ...options, cacheMode: 'refresh' })); + } + } + + async #verify(skill: Skill, target: ReadTarget, result: ReadResourceResult): Promise { + const { uri, root, key, listed } = target; + const content = result.contents.find(item => (skillFileKey(root, item.uri) ?? item.uri) === key); + if (content === undefined) throw new SdkError(SdkErrorCode.InvalidResult, `resources/read returned no contents for ${uri}`); + const bytes = 'text' in content ? encodeUtf8(content.text) : decodeBlob(uri, content.blob); + if (listed !== undefined) { + if (bytes.byteLength !== listed.size) { + throw new SkillVerificationError('size', uri, `${uri} is ${bytes.byteLength} bytes; the skill entry lists ${listed.size}`); + } + const digest = await sha256Digest(bytes); + if (digest !== listed.digest) { + throw new SkillVerificationError('digest', uri, `${uri} has digest ${digest}; the skill entry lists ${listed.digest}`); + } + } + const file: SkillFile = { + uri, + ...(content.mimeType !== undefined && { mimeType: content.mimeType }), + bytes, + ...('text' in content && { text: content.text }), + verified: listed !== undefined + }; + if (!target.manifest) return file; + const markdown = 'text' in content ? content.text : decodeUtf8Lossless(bytes); + if (markdown === undefined) throw new SkillVerificationError('frontmatter', uri, `${uri} is not valid UTF-8`); + const { frontmatter, body } = this.#parse(uri, markdown); + const differences = frontmatterDifferences(skill.frontmatter, frontmatter); + if (differences.length > 0) { + throw new SkillVerificationError( + 'frontmatter', + uri, + `the frontmatter of ${uri} differs from the skill entry: ${differences.join('; ')}`, + differences + ); + } + return { ...file, text: markdown, frontmatter, body }; + } + + #parse(uri: string, markdown: string): { frontmatter: Record; body: string } { + try { + const parse = this.#options.parseFrontmatter; + if (parse === undefined) return parseSkillMarkdown(markdown); + const { yaml, body } = splitFrontmatter(markdown); + return { frontmatter: parse(yaml), body }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new SkillVerificationError('frontmatter', uri, `the frontmatter of ${uri} cannot be read: ${reason}`, [], { + cause: error + }); + } + } + + /** The direct children of a skill directory, every page walked. Needs `directoryRead`. */ + async readDirectory(uri: string, options?: RequestOptions): Promise { + if (!this.directoryRead) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not declare directoryRead for ${SKILLS_EXTENSION_ID} (required for ${RESOURCES_DIRECTORY_READ_METHOD})` + ); + } + const page = await this.#pages( + RESOURCES_DIRECTORY_READ_METHOD, + ReadResourceDirectoryResultSchema, + { uri }, + undefined, + options, + result => result.resources + ); + return page.resources; + } + + /** One page (with `cursor`), or every page in one, walked as Client walks its own list verbs. */ + #pages( + method: string, + schema: z.ZodType, + params: Record, + cursor: string | undefined, + options: RequestOptions | undefined, + items: (page: T) => unknown[] + ): Promise { + const fetchPage = (at: string | undefined): Promise => + this.#client.request({ method, params: at === undefined ? params : { ...params, cursor: at } }, schema, options); + return cursor === undefined ? clientInternals.walkPages(this.#client, method, fetchPage, items) : fetchPage(cursor); + } +} diff --git a/packages/client/src/ext/skills/verify.ts b/packages/client/src/ext/skills/verify.ts new file mode 100644 index 0000000000..b9b0f15649 --- /dev/null +++ b/packages/client/src/ext/skills/verify.ts @@ -0,0 +1,32 @@ +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const show = (value: unknown): string => (value === undefined ? 'nothing' : JSON.stringify(value)); + +const fieldOf = (path: string, key: string): string => (path === '' ? key : `${path}.${key}`); + +/** Every field a reader of `object` sees, inherited ones included; Object.prototype's (`constructor`, `__proto__`) are not enumerable. */ +function fieldsOf(object: Record): Map { + const fields = new Map(); + for (const key in object) fields.set(key, object[key]); + return fields; +} + +/** + * Field-level differences between a skill entry's frontmatter and the + * frontmatter parsed from its `SKILL.md`: every key both ways, objects in any + * key order, arrays in order, numbers by value, no type coercion. + */ +export function frontmatterDifferences(entry: unknown, parsed: unknown, path = ''): string[] { + const label = path === '' ? '(frontmatter)' : path; + if (Array.isArray(entry) && Array.isArray(parsed)) { + if (entry.length !== parsed.length) return [`${label}: the entry has ${entry.length} items, SKILL.md has ${parsed.length}`]; + return entry.flatMap((item, index) => frontmatterDifferences(item, parsed[index], `${path}[${index}]`)); + } + if (isPlainObject(entry) && isPlainObject(parsed)) { + const [fromEntry, fromParsed] = [fieldsOf(entry), fieldsOf(parsed)]; + const keys = [...new Set([...fromEntry.keys(), ...fromParsed.keys()])].toSorted(); + return keys.flatMap(key => frontmatterDifferences(fromEntry.get(key), fromParsed.get(key), fieldOf(path, key))); + } + return entry === parsed ? [] : [`${label}: the entry has ${show(entry)}, SKILL.md has ${show(parsed)}`]; +} diff --git a/packages/client/test/client/barrelClean.test.ts b/packages/client/test/client/barrelClean.test.ts index 567543deed..94412bba87 100644 --- a/packages/client/test/client/barrelClean.test.ts +++ b/packages/client/test/client/barrelClean.test.ts @@ -14,20 +14,27 @@ const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\ // Anchored at start-of-line so JSDoc-example `from 'ajv'` strings in vendored chunks don't match. const VALIDATOR_BACKEND_IMPORT = /^import[^\n]*?from\s+["'](?:ajv|ajv-formats|@cfworker\/json-schema)["']/m; const ROOT_VALIDATOR_EXPORTS = ['AjvJsonSchemaValidator', 'CfWorkerJsonSchemaValidator', 'CfWorkerSchemaDraft']; +// The skills extension's shared schemas: a static, side-effect or dynamic import of the external subpath. +const CORE_SKILLS_IMPORT = /\b(?:from|import)\s*\(?\s*["']@modelcontextprotocol\/core\/ext\/skills["']/; +// The skills extension's own code (core-internal's `ext/skills`): the frontmatter parser and the entry builder. +const SKILLS_CODE = /\b(?:FrontmatterError|parseFrontmatterYaml|buildSkillEntry|skillEntryProblems)\b/; -function chunkImportsOf(entryPath: string): string[] { +/** + * An ESM entry and every chunk it imports, following relative `from` and side-effect `import` specifiers, both + * `./` and `../` (nested entries import `../../chunk.mjs`, and side-effect-only `import "../../chunk.mjs"`), with or + * without a space before the quote (`from"./x.mjs"`). Same walker as the server package's barrelClean test. + */ +function moduleGraphOf(entryPath: string): string[] { const visited = new Set(); const queue = [entryPath]; while (queue.length > 0) { const file = queue.shift()!; if (visited.has(file)) continue; visited.add(file); - const src = readFileSync(file, 'utf8'); - for (const m of src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)) { + for (const m of readFileSync(file, 'utf8').matchAll(/\b(?:from|import)\s*["'](\.{1,2}\/[^"']+?\.mjs)["']/g)) { queue.push(join(dirname(file), m[1]!)); } } - visited.delete(entryPath); return [...visited]; } @@ -49,7 +56,7 @@ describe('@modelcontextprotocol/client root entry is browser-safe', () => { test('chunks transitively imported by dist/index.mjs contain no process-spawning runtime imports', () => { const entry = join(distDir, 'index.mjs'); - for (const chunk of chunkImportsOf(entry)) { + for (const chunk of moduleGraphOf(entry)) { expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( expect.objectContaining({ content: expect.stringMatching(NODE_ONLY) }) ); @@ -68,7 +75,7 @@ describe('@modelcontextprotocol/client root entry is browser-safe', () => { const entry = join(distDir, shim); expect(readFileSync(entry, 'utf8')).not.toMatch(VALIDATOR_BACKEND_IMPORT); - for (const chunk of chunkImportsOf(entry)) { + for (const chunk of moduleGraphOf(entry)) { expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( expect.objectContaining({ content: expect.stringMatching(VALIDATOR_BACKEND_IMPORT) }) ); @@ -96,3 +103,30 @@ describe('@modelcontextprotocol/client root entry is browser-safe', () => { } }); }); + +describe('@modelcontextprotocol/client root leaves the skills extension out', () => { + beforeAll(async () => { + await ensureBuilt(pkgDir); + }, 180_000); + + test('the root entry does not export the skills extension', () => { + expect(rootExportBlockOf(readFileSync(join(distDir, 'index.mjs'), 'utf8'))).not.toMatch( + /\b(?:SkillsClient|SkillVerificationError)\b/ + ); + }); + + test('dist/index.mjs and every chunk it imports carry no skills code and no @modelcontextprotocol/core/ext/skills import', () => { + // Users who never import ext/skills must not load (or bundle) the frontmatter parser or the skills schemas. + for (const file of moduleGraphOf(join(distDir, 'index.mjs'))) { + const content = readFileSync(file, 'utf8'); + expect({ file, content }).not.toEqual(expect.objectContaining({ content: expect.stringMatching(CORE_SKILLS_IMPORT) })); + expect({ file, content }).not.toEqual(expect.objectContaining({ content: expect.stringMatching(SKILLS_CODE) })); + } + }); + + test('the ext/skills entry graph is where the skills code and the core/ext/skills import live (guards the patterns)', () => { + const graph = moduleGraphOf(join(distDir, 'ext/skills/index.mjs')).map(file => readFileSync(file, 'utf8')); + expect(graph.some(content => CORE_SKILLS_IMPORT.test(content))).toBe(true); + expect(graph.some(content => SKILLS_CODE.test(content))).toBe(true); + }); +}); diff --git a/packages/client/test/client/errorBrandConformance.test.ts b/packages/client/test/client/errorBrandConformance.test.ts index 7265b8c29b..2e709a749a 100644 --- a/packages/client/test/client/errorBrandConformance.test.ts +++ b/packages/client/test/client/errorBrandConformance.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; +import * as skills from '../../src/ext/skills/index'; import * as barrel from '../../src/index'; +/** Every public entry that can export an error class: the root and the skills extension's subpath. */ +const SURFACES: ReadonlyArray> = [barrel, skills]; + /** * Enforces the cross-bundle branding participation criterion from * `core-internal/src/errors/crossBundleBrand.ts`: every error class exported @@ -17,22 +21,26 @@ import * as barrel from '../../src/index'; /** Error classes exported on purpose without a brand. Justify every entry. */ const UNBRANDED_ALLOWLIST: ReadonlySet = new Set(); -function exportedErrorClasses(mod: Record): Array<[string, Function]> { - return Object.entries(mod) - .filter((entry): entry is [string, Function] => { - const v = entry[1]; - return typeof v === 'function' && v !== Error && v.prototype instanceof Error; - }) - .sort(([a], [b]) => a.localeCompare(b)); +function exportedErrorClasses(surfaces: ReadonlyArray>): Array<[string, Function]> { + const found = new Map(); + for (const surface of surfaces) { + for (const [name, v] of Object.entries(surface)) { + if (typeof v !== 'function' || v === Error || !(v.prototype instanceof Error)) continue; + if (found.has(name) && found.get(name) !== v) throw new Error(`two entries export different error classes named ${name}`); + found.set(name, v); + } + } + return [...found].sort(([a], [b]) => a.localeCompare(b)); } describe('error brand conformance (client export surface)', () => { - const classes = exportedErrorClasses(barrel as Record); + const classes = exportedErrorClasses(SURFACES); it('finds the export surface (guards against a broken walker)', () => { expect(classes.length).toBeGreaterThanOrEqual(15); expect(classes.map(([n]) => n)).toContain('ProtocolError'); expect(classes.map(([n]) => n)).toContain('OAuthClientFlowError'); + expect(classes.map(([n]) => n)).toContain('SkillVerificationError'); }); it('every exported error class is branded (or explicitly allowlisted)', () => { @@ -81,6 +89,7 @@ describe('error brand conformance (client export surface)', () => { IssuerMismatchError: 'mcp.IssuerMismatchError', OAuthClientFlowError: 'mcp.OAuthClientFlowError', RegistrationRejectedError: 'mcp.RegistrationRejectedError', + SkillVerificationError: 'mcp.SkillVerificationError', SseError: 'mcp.SseError', UnauthorizedError: 'mcp.UnauthorizedError' }); diff --git a/packages/client/test/ext/skills/fixtures.ts b/packages/client/test/ext/skills/fixtures.ts new file mode 100644 index 0000000000..a1a7cded0f --- /dev/null +++ b/packages/client/test/ext/skills/fixtures.ts @@ -0,0 +1,57 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SKILLS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/skills'; +import type { ServerCapabilities } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, encodeSegment, encodeUtf8 } from '@modelcontextprotocol/core-internal/ext/skills'; + +export const CAPABILITIES: ServerCapabilities = { resources: {}, extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: true } } }; + +export const PDF_ROOT = 'skill://pdf-processing'; +export const PDF_SKILL_MD = `--- +name: pdf-processing +description: Extract text and fill forms in PDF files. +metadata: + author: acme + version: "1.0" +--- +# PDF processing + +See [the forms guide](references/FORMS.md). +`; +export const FORMS_MD = `# Forms + +Fill the fields in order. +`; +export const PDF_FILES: Readonly> = { + 'SKILL.md': PDF_SKILL_MD, + 'references/FORMS.md': FORMS_MD, + 'templates/Purchase Order.md': '# PO\n' +}; + +/** The URI a conforming server publishes for `path` inside the skill at `root`: each segment percent-encoded. */ +export function uriOf(root: string, path: string): string { + return `${root}/${path + .split('/') + .map(segment => encodeSegment(segment)) + .join('/')}`; +} + +/** File contents keyed by published URI. */ +export function filesOf(root: string, files: Readonly>): Map { + return new Map(Object.entries(files).map(([path, content]) => [uriOf(root, path), content])); +} + +/** A valid entry for these files, as a conforming server publishes it. */ +export async function entryFor(root: string, files: Readonly>): Promise { + const inputs = Object.entries(files).map(([path, content]) => ({ + uri: uriOf(root, path), + bytes: typeof content === 'string' ? encodeUtf8(content) : content + })); + const { skill, problems } = await buildSkillEntry(`${root}/SKILL.md`, inputs); + if (skill === undefined || problems.length > 0) throw new Error(problems.join('; ')); + return skill; +} + +/** A valid skill called `name` with only a `SKILL.md`. */ +export function namedSkill(name: string): Promise { + return entryFor(`skill://${name}`, { 'SKILL.md': `---\nname: ${name}\ndescription: The ${name} skill.\n---\n` }); +} diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts new file mode 100644 index 0000000000..9adb71355e --- /dev/null +++ b/packages/client/test/ext/skills/read.test.ts @@ -0,0 +1,370 @@ +import type { Skill, SkillResourceEntry } from '@modelcontextprotocol/core/ext/skills'; +import { SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, encodeUtf8, joinUri, sha256Digest } from '@modelcontextprotocol/core-internal/ext/skills'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SkillsClientOptions } from '../../../src/ext/skills/index'; +import { SkillsClient, SkillVerificationError } from '../../../src/ext/skills/index'; +import { CAPABILITIES, entryFor, filesOf, FORMS_MD, PDF_FILES, PDF_ROOT, PDF_SKILL_MD, uriOf } from './fixtures'; +import type { Call } from './scripted'; +import { scriptedClient, skillsHandlers } from './scripted'; + +const MANIFEST = `${PDF_ROOT}/SKILL.md`; +const FORMS = `${PDF_ROOT}/references/FORMS.md`; + +interface Setup { + files?: Readonly>; + options?: SkillsClientOptions; + readTtlMs?: number; +} + +async function connected({ files = PDF_FILES, options = {}, readTtlMs }: Setup = {}) { + const skill = await entryFor(PDF_ROOT, files); + const served = filesOf(PDF_ROOT, files); + const { client, calls } = await scriptedClient(CAPABILITIES, skillsHandlers([skill], served, { readTtlMs })); + return { skill, served, calls, skills: new SkillsClient(client, options) }; +} + +/** A server that publishes exactly these URIs, however it spells them, with these contents. */ +async function publishedAs(contents: ReadonlyMap) { + const { skill, problems } = await buildSkillEntry( + MANIFEST, + [...contents].map(([uri, text]) => ({ uri, bytes: encodeUtf8(text) })) + ); + if (skill === undefined || problems.length > 0) throw new Error(problems.join('; ')); + const { client, calls } = await scriptedClient(CAPABILITIES, skillsHandlers([skill], contents)); + return { skill, calls, skills: new SkillsClient(client) }; +} + +const reads = (calls: Call[]): unknown[] => calls.filter(call => call.method === 'resources/read').map(call => call.params['uri']); + +describe('SkillsClient.read', () => { + it("reads and verifies the skill's own SKILL.md", async () => { + const { skills, skill } = await connected(); + const file = await skills.read(skill); + expect(file).toMatchObject({ + uri: MANIFEST, + text: PDF_SKILL_MD, + verified: true, + frontmatter: { name: 'pdf-processing', metadata: { author: 'acme', version: '1.0' } }, + body: expect.stringContaining('# PDF processing') + }); + expect(file.bytes).toEqual(encodeUtf8(PDF_SKILL_MD)); + }); + + it('resolves a relative path or an absolute URI against the skill root', async () => { + const { skills, skill } = await connected(); + for (const ref of ['references/FORMS.md', './references/FORMS.md', 'references/../references/FORMS.md', FORMS]) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: FORMS, text: FORMS_MD, verified: true }); + } + }); + + it('resolves file names with a space to the URI the server published', async () => { + const { skills, skill } = await connected(); + const published = uriOf(PDF_ROOT, 'templates/Purchase Order.md'); + expect(published).toBe('skill://pdf-processing/templates/Purchase%20Order.md'); + expect(await skills.read(skill, 'templates/Purchase Order.md')).toMatchObject({ uri: published, text: '# PO\n', verified: true }); + }); + + it('resolves non-ASCII file names to the URI the server published', async () => { + const { skills, skill } = await connected({ files: { ...PDF_FILES, 'references/résumé.md': '# CV\n' } }); + const published = uriOf(PDF_ROOT, 'references/résumé.md'); + expect(published).toBe('skill://pdf-processing/references/r%C3%A9sum%C3%A9.md'); + for (const ref of ['references/résumé.md', 'references/r%c3%a9sum%c3%a9.md']) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: published, text: '# CV\n', verified: true }); + } + }); + + it('matches reserved characters in a name however the ref spells them', async () => { + const { skills, skill, calls } = await connected({ files: { ...PDF_FILES, 'references/Q&A.md': '# Q&A\n' } }); + const published = uriOf(PDF_ROOT, 'references/Q&A.md'); + expect(published).toBe('skill://pdf-processing/references/Q&A.md'); + for (const ref of ['references/Q&A.md', 'references/Q%26A.md']) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: published, text: '# Q&A\n', verified: true }); + } + expect(reads(calls)).toEqual([published, published]); + }); + + it('verifies a file a server published with more encoding than ours, requesting its spelling', async () => { + const overEncoded = `${PDF_ROOT}/references/Q%26A.md`; + const { skills, skill, calls } = await publishedAs( + new Map([ + [MANIFEST, PDF_SKILL_MD], + [overEncoded, '# Q&A\n'] + ]) + ); + for (const ref of ['references/Q&A.md', 'references/Q%26A.md']) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: overEncoded, text: '# Q&A\n', verified: true }); + } + expect(reads(calls)).toEqual([overEncoded, overEncoded]); + }); + + it('requests a dynamic skill file at the URI its ref resolves to', async () => { + const overEncoded = `${PDF_ROOT}/references/Q%26A.md`; + const { skill, calls, skills } = await publishedAs( + new Map([ + [MANIFEST, PDF_SKILL_MD], + [overEncoded, '# Q&A\n'] + ]) + ); + const dynamic: Skill = { ...skill, resources: 'dynamic' }; + expect(await skills.read(dynamic, 'references/Q%26A.md')).toMatchObject({ uri: overEncoded, text: '# Q&A\n', verified: false }); + expect(reads(calls)).toEqual([overEncoded]); + }); + + it('accepts contents that spell the requested file differently', async () => { + const skill = await entryFor(PDF_ROOT, { ...PDF_FILES, 'references/Q&A.md': '# Q&A\n' }); + const echoed = `${PDF_ROOT}/references/Q%26A.md`; + const { client } = await scriptedClient(CAPABILITIES, { + ...skillsHandlers([skill], new Map()), + 'resources/read': () => ({ contents: [{ uri: echoed, text: '# Q&A\n' }], ttlMs: 0, cacheScope: 'private' }) + }); + const skills = new SkillsClient(client); + for (const target of [skill, { ...skill, resources: 'dynamic' } satisfies Skill]) { + expect(await skills.read(target, 'references/Q&A.md')).toMatchObject({ text: '# Q&A\n' }); + } + }); + + it('refuses an invalid entry before any request: a file listed in two spellings, or outside the skill', async () => { + const { skills, skill, calls } = await connected({ files: { ...PDF_FILES, 'references/Q&A.md': '# Q&A\n' } }); + const listed = skill.resources as SkillResourceEntry[]; + const qa = listed.find(resource => resource.uri.endsWith('/Q&A.md'))!; + const twice: Skill = { ...skill, resources: [...listed, { ...qa, uri: `${PDF_ROOT}/references/Q%26A.md` }] }; + const outside: Skill = { ...skill, resources: [...listed, { ...qa, uri: 'skill://other-skill/Q&A.md' }] }; + for (const [entry, ref, reason] of [ + [twice, 'references/Q&A.md', /more than once/], + [outside, 'skill://other-skill/Q&A.md', /outside the skill directory/] + ] as const) { + const error = await skills.read(entry, ref).catch((error: unknown) => error); + expect(error).toBeInstanceOf(TypeError); + expect(error).toMatchObject({ message: expect.stringMatching(reason) }); + } + expect(reads(calls)).toEqual([]); + }); + + it("checks SKILL.md's frontmatter however the ref spells it", async () => { + const { skills, skill, calls } = await connected(); + expect(await skills.read(skill, '%53KILL.md')).toMatchObject({ uri: MANIFEST, frontmatter: { name: 'pdf-processing' } }); + const tampered: Skill = { ...skill, frontmatter: { ...skill.frontmatter, description: 'Something else.' } }; + await expect(skills.read(tampered, '%53KILL.md')).rejects.toMatchObject({ reason: 'frontmatter', uri: MANIFEST }); + expect(reads(calls)).toEqual([MANIFEST, MANIFEST]); + }); + + it('drops a fragment from the ref', async () => { + const { skills, skill, calls } = await connected(); + expect(await skills.read(skill, 'references/FORMS.md#fields')).toMatchObject({ uri: FORMS, verified: true }); + const dynamic: Skill = { ...skill, resources: 'dynamic' }; + expect(await skills.read(dynamic, 'references/FORMS.md#fields')).toMatchObject({ uri: FORMS, verified: false }); + expect(reads(calls)).toEqual([FORMS, FORMS]); + }); + + it('refuses a ref that is not a URI reference, before any request', async () => { + const { skills, skill, calls } = await connected(); + const error = await skills.read(skill, 'http://[').catch((error: unknown) => error); + expect(error).toBeInstanceOf(SkillVerificationError); + expect(error).toMatchObject({ reason: 'unlisted', uri: 'http://[' }); + expect(reads(calls)).toEqual([]); + }); + + it.each([ + ['100% off.md', '100%25%20off.md'], + ['10%Dec.md', '10%25Dec.md'], + ['%Café.md', '%25Caf%C3%A9.md'] + ])('reads %s, a name with a stray %, by its plain name', async (name, encoded) => { + const { skills, skill } = await connected({ files: { ...PDF_FILES, [`references/${name}`]: '# Sale\n' } }); + const published = uriOf(PDF_ROOT, `references/${name}`); + expect(published).toBe(`skill://pdf-processing/references/${encoded}`); + expect(await skills.read(skill, `references/${name}`)).toMatchObject({ uri: published, text: '# Sale\n', verified: true }); + }); + + it.each(['me@home', 'acme:prod', '[draft]'])('reads and matches files of a skill under the %s folder or prefix', async first => { + const root = joinUri('skill://', [first, 'pdf-processing']); + expect(root).toBe(`skill://${encodeURIComponent(first)}/pdf-processing`); + const files = { ...PDF_FILES, 'references/Q&A.md': '# Q&A\n' }; + const skill = await entryFor(root, files); + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers([skill], filesOf(root, files))); + const skills = new SkillsClient(client); + expect(await skills.read(skill)).toMatchObject({ uri: `${root}/SKILL.md`, verified: true }); + for (const ref of ['references/Q&A.md', 'references/Q%26A.md', `${root}/references/Q&A.md`]) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: `${root}/references/Q&A.md`, text: '# Q&A\n', verified: true }); + } + }); + + it('refuses unlisted files before sending any request', async () => { + const { skills, skill, calls } = await connected(); + for (const ref of ['references/MISSING.md', '../../etc/passwd', 'skill://other-skill/SKILL.md', 'https://example.com/x']) { + await expect(skills.read(skill, ref)).rejects.toMatchObject({ reason: 'unlisted' }); + } + expect(reads(calls)).toEqual([]); + }); + + it('rejects a file whose size differs from the entry', async () => { + const { skills, skill, served } = await connected(); + served.set(FORMS, '# Forms, but longer\n'); + await expect(skills.read(skill, 'references/FORMS.md')).rejects.toMatchObject({ reason: 'size', uri: FORMS }); + }); + + it('rejects a file whose digest differs from the entry', async () => { + const { skills, skill, served } = await connected(); + served.set(FORMS, FORMS_MD.toUpperCase()); + const error = await skills.read(skill, 'references/FORMS.md').catch((error: unknown) => error); + expect(error).toBeInstanceOf(SkillVerificationError); + expect(error).toMatchObject({ reason: 'digest', uri: FORMS, code: SdkErrorCode.InvalidResult }); + }); + + it('rejects a SKILL.md whose frontmatter differs from the entry, naming the field', async () => { + const { skills, skill } = await connected(); + const tampered: Skill = { ...skill, frontmatter: { ...skill.frontmatter, metadata: { author: 'someone-else', version: '1.0' } } }; + await expect(skills.read(tampered)).rejects.toMatchObject({ + reason: 'frontmatter', + differences: [expect.stringContaining('metadata.author')] + }); + }); + + it('sees fields either side inherits through a prototype', async () => { + const inheriting = (fields: object, inherited: object) => Object.assign(Object.create(inherited) as object, fields); + const { skills, skill } = await connected(); + const entry: Skill = { + ...skill, + frontmatter: inheriting(skill.frontmatter, { 'allowed-tools': 'Bash(*)' }) as Skill['frontmatter'] + }; + await expect(skills.read(entry)).rejects.toMatchObject({ + reason: 'frontmatter', + differences: ['allowed-tools: the entry has "Bash(*)", SKILL.md has nothing'] + }); + const parseFrontmatter = () => inheriting(skill.frontmatter, { 'allowed-tools': 'Bash(*)' }) as Record; + const parsing = await connected({ options: { parseFrontmatter } }); + await expect(parsing.skills.read(parsing.skill)).rejects.toMatchObject({ + reason: 'frontmatter', + differences: ['allowed-tools: the entry has nothing, SKILL.md has "Bash(*)"'] + }); + }); + + it('fails closed on YAML outside the supported subset', async () => { + const markdown = '---\nname: &n pdf-processing\ndescription: d\n---\n'; + const bytes = encodeUtf8(markdown); + const skill: Skill = { + uri: MANIFEST, + frontmatter: { name: 'pdf-processing', description: 'd' }, + resources: [{ uri: MANIFEST, digest: await sha256Digest(bytes), size: bytes.byteLength }] + }; + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers([skill], new Map([[MANIFEST, markdown]]))); + await expect(new SkillsClient(client).read(skill)).rejects.toMatchObject({ reason: 'frontmatter' }); + }); + + it('passes parseFrontmatter only the YAML between the --- lines', async () => { + const parseFrontmatter = vi.fn(() => ({ + name: 'pdf-processing', + description: 'Extract text and fill forms in PDF files.', + metadata: { author: 'acme', version: '1.0' } + })); + const { skills, skill } = await connected({ options: { parseFrontmatter } }); + expect(await skills.read(skill)).toMatchObject({ verified: true, body: expect.stringContaining('# PDF processing') }); + expect(parseFrontmatter).toHaveBeenCalledWith(`name: pdf-processing +description: Extract text and fill forms in PDF files. +metadata: + author: acme + version: "1.0" +`); + }); + + it('passes parseFrontmatter the YAML of a Windows-authored SKILL.md with the BOM and CRLF removed', async () => { + const windows = { 'SKILL.md': '---\r\nname: pdf-processing\r\ndescription: Extract text.\r\n---\r\n# PDF\r\n' }; + const parseFrontmatter = vi.fn(() => ({ name: 'pdf-processing', description: 'Extract text.' })); + const { skills, skill } = await connected({ files: windows, options: { parseFrontmatter } }); + expect(await skills.read(skill)).toMatchObject({ verified: true }); + expect(parseFrontmatter).toHaveBeenCalledWith('name: pdf-processing\ndescription: Extract text.\n'); + }); + + it('verifies a Windows-authored SKILL.md (BOM and CRLF)', async () => { + const windows = { 'SKILL.md': '---\r\nname: pdf-processing\r\ndescription: Extract text.\r\n---\r\n# PDF\r\n' }; + const { skills, skill } = await connected({ files: windows }); + expect(await skills.read(skill)).toMatchObject({ + verified: true, + frontmatter: { name: 'pdf-processing', description: 'Extract text.' } + }); + }); + + it('decodes and verifies binary files', async () => { + const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const { skills, skill } = await connected({ files: { ...PDF_FILES, 'assets/logo.png': png } }); + const file = await skills.read(skill, 'assets/logo.png'); + expect(file).toMatchObject({ verified: true, mimeType: 'application/octet-stream' }); + expect(file.bytes).toEqual(png); + expect(file).not.toHaveProperty('text'); + }); + + it("treats a nested skill's SKILL.md as an ordinary file of the enclosing skill", async () => { + const { skills, skill } = await connected({ + files: { + ...PDF_FILES, + 'forms/SKILL.md': `--- +name: forms +description: Nested. +--- +` + } + }); + const file = await skills.read(skill, 'forms/SKILL.md'); + expect(file).toMatchObject({ verified: true }); + expect(file).not.toHaveProperty('frontmatter'); + }); + + it('reads dynamic skills unverified but still compares SKILL.md frontmatter', async () => { + const { skills, skill } = await connected(); + const dynamic: Skill = { ...skill, resources: 'dynamic' }; + expect(await skills.read(dynamic, 'references/FORMS.md')).toMatchObject({ verified: false, text: FORMS_MD }); + expect(await skills.read(dynamic)).toMatchObject({ verified: false, frontmatter: { name: 'pdf-processing' } }); + const tampered = { ...dynamic, frontmatter: { ...dynamic.frontmatter, description: 'other' } }; + await expect(skills.read(tampered)).rejects.toMatchObject({ reason: 'frontmatter' }); + await expect(skills.read(dynamic, 'skill://other-skill/SKILL.md')).rejects.toMatchObject({ reason: 'unlisted' }); + }); + + it('fetches again once when a cached copy fails verification', async () => { + const { skills, skill, served, calls } = await connected({ readTtlMs: 60_000 }); + await skills.read(skill, 'references/FORMS.md'); + served.set(FORMS, '# Forms v2\n'); + const updated = await entryFor(PDF_ROOT, { ...PDF_FILES, 'references/FORMS.md': '# Forms v2\n' }); + expect(await skills.read(updated, 'references/FORMS.md')).toMatchObject({ text: '# Forms v2\n', verified: true }); + expect(reads(calls)).toEqual([FORMS, FORMS]); + }); + + it('gives up when the fresh copy fails as well', async () => { + const { skills, skill, served, calls } = await connected({ readTtlMs: 60_000 }); + await skills.read(skill, 'references/FORMS.md'); + served.set(FORMS, '# Forms v2\n'); + const other = await entryFor(PDF_ROOT, { ...PDF_FILES, 'references/FORMS.md': '# Forms v3\n' }); + await expect(skills.read(other, 'references/FORMS.md')).rejects.toMatchObject({ reason: 'digest' }); + expect(reads(calls)).toEqual([FORMS, FORMS]); + }); + + it('fetches no second copy when the failing one came from the server', async () => { + const { skills, skill, served, calls } = await connected({ readTtlMs: 60_000 }); + served.set(FORMS, FORMS_MD.toUpperCase()); + for (const cacheMode of [undefined, 'refresh', 'bypass'] as const) { + await expect(skills.read(skill, 'references/FORMS.md', { cacheMode })).rejects.toMatchObject({ reason: 'digest' }); + } + expect(reads(calls)).toEqual([FORMS, FORMS, FORMS]); + }); + + it('refuses contents for a URI other than the one requested', async () => { + const skill = await entryFor(PDF_ROOT, PDF_FILES); + const other = { uri: `${PDF_ROOT}/references/OTHER.md`, mimeType: 'text/markdown', text: FORMS_MD }; + const { client } = await scriptedClient(CAPABILITIES, { + ...skillsHandlers([skill], filesOf(PDF_ROOT, PDF_FILES)), + 'resources/read': () => ({ contents: [other], ttlMs: 0, cacheScope: 'private' }) + }); + const skills = new SkillsClient(client); + for (const target of [skill, { ...skill, resources: 'dynamic' } satisfies Skill]) { + const error = await skills.read(target, 'references/FORMS.md').catch((error: unknown) => error); + expect(error).not.toBeInstanceOf(SkillVerificationError); + expect(error).toMatchObject({ code: SdkErrorCode.InvalidResult, message: expect.stringContaining(FORMS) }); + } + }); + + it('throws CapabilityNotSupported on a server without the extension', async () => { + const skill = await entryFor(PDF_ROOT, PDF_FILES); + const { client } = await scriptedClient({ resources: {} }, {}); + await expect(new SkillsClient(client).read(skill)).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); + }); +}); diff --git a/packages/client/test/ext/skills/scripted.ts b/packages/client/test/ext/skills/scripted.ts new file mode 100644 index 0000000000..9cbab374db --- /dev/null +++ b/packages/client/test/ext/skills/scripted.ts @@ -0,0 +1,121 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import type { JSONRPCRequest, ServerCapabilities } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; + +import type { ClientOptions } from '../../../src/client/client'; +import { Client } from '../../../src/client/client'; + +export const MODERN = '2026-07-28'; + +export type Handler = (params: Record) => Record | Promise>; + +export interface Call { + method: string; + params: Record; +} + +/** A JSON-RPC error for the scripted server to answer with. */ +export class RpcError extends Error { + constructor( + readonly code: number, + message: string + ) { + super(message); + } +} + +/** + * A client connected on 2026-07-28 to a scripted server: `server/discover` + * declares `capabilities`, every other request goes to `handlers` and is + * recorded in `calls`. Results carry `resultType: "complete"` as a conforming + * server's do (the typescript-sdk#2789 regression). + */ +export async function scriptedClient( + capabilities: ServerCapabilities, + handlers: Record, + options: ClientOptions = {} +): Promise<{ client: Client; calls: Call[] }> { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const calls: Call[] = []; + const answer = async (request: JSONRPCRequest): Promise> => { + if (request.method === 'server/discover') { + return { + supportedVersions: [MODERN], + capabilities, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted', version: '1.0.0' } } + }; + } + const params = (request.params ?? {}) as Record; + calls.push({ method: request.method, params }); + const handler = handlers[request.method]; + if (handler === undefined) throw new RpcError(-32_601, `Method not found: ${request.method}`); + return handler(params); + }; + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + if (request.id === undefined) return; + void answer(request).then( + result => serverTx.send({ jsonrpc: '2.0', id: request.id, result: { resultType: 'complete', ...result } }), + (error: unknown) => + serverTx.send({ + jsonrpc: '2.0', + id: request.id, + error: { + code: error instanceof RpcError ? error.code : -32_603, + message: error instanceof Error ? error.message : String(error) + } + }) + ); + }; + await serverTx.start(); + const client = new Client( + { name: 'skills-test-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, ...options } + ); + await client.connect(clientTx); + return { client, calls }; +} + +export interface SkillsHandlerOptions { + /** Entries per `skills/list` page. Default: all. */ + pageSize?: number; + /** `ttlMs` on `resources/read` results; above 0 the client caches them. Default: 0 (not cached). */ + readTtlMs?: number; +} + +/** A conforming skills server over `skills` and the file contents in `files` (keyed by URI as published). */ +export function skillsHandlers( + skills: readonly Skill[], + files: ReadonlyMap, + options: SkillsHandlerOptions = {} +): Record { + const pageSize = options.pageSize ?? Number.POSITIVE_INFINITY; + return { + 'skills/list': params => { + const start = params['cursor'] === undefined ? 0 : Number(params['cursor']); + const end = start + pageSize; + return { + skills: skills.slice(start, end), + ttlMs: 60_000, + cacheScope: 'public', + ...(end < skills.length && { nextCursor: String(end) }) + }; + }, + 'skills/get': params => { + const skill = skills.find(candidate => candidate.uri === params['uri']); + if (skill === undefined) throw new RpcError(-32_602, `No skill is served at ${String(params['uri'])}`); + return { skill, ttlMs: 0, cacheScope: 'private' }; + }, + 'resources/read': params => { + const uri = String(params['uri']); + const content = files.get(uri); + if (content === undefined) throw new RpcError(-32_602, `Resource not found: ${uri}`); + const body = + typeof content === 'string' + ? { uri, mimeType: 'text/markdown', text: content } + : { uri, mimeType: 'application/octet-stream', blob: Buffer.from(content).toString('base64') }; + // 2026-07-28 requires both caching fields on every cacheable result. + return { contents: [body], ttlMs: options.readTtlMs ?? 0, cacheScope: 'private' }; + } + }; +} diff --git a/packages/client/test/ext/skills/skillsClient.test.ts b/packages/client/test/ext/skills/skillsClient.test.ts new file mode 100644 index 0000000000..5cd7bb5dd1 --- /dev/null +++ b/packages/client/test/ext/skills/skillsClient.test.ts @@ -0,0 +1,212 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SKILLS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/skills'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../../src/client/client'; +import { SkillsClient } from '../../../src/ext/skills/index'; +import { CAPABILITIES, namedSkill, PDF_ROOT } from './fixtures'; +import type { Call, Handler } from './scripted'; +import { RpcError, scriptedClient, skillsHandlers } from './scripted'; + +const NO_DIRECTORIES = { resources: {}, extensions: { [SKILLS_EXTENSION_ID]: {} } }; +const DIGEST = `sha256:${'0'.repeat(64)}`; + +const methodCalls = (calls: Call[], method: string): Call[] => calls.filter(call => call.method === method); + +async function threeSkills(): Promise { + return Promise.all(['alpha', 'beta', 'gamma'].map(name => namedSkill(name))); +} + +describe('SkillsClient capability', () => { + it('is unsupported before connect', () => { + const skills = new SkillsClient(new Client({ name: 'c', version: '1.0.0' })); + expect(skills.supported).toBe(false); + expect(skills.directoryRead).toBe(false); + }); + + it('reads the declared extension and directoryRead', async () => { + const full = await scriptedClient(CAPABILITIES, {}); + const withDirectories = new SkillsClient(full.client); + expect([withDirectories.supported, withDirectories.directoryRead]).toEqual([true, true]); + const partial = await scriptedClient(NO_DIRECTORIES, {}); + const withoutDirectories = new SkillsClient(partial.client); + expect([withoutDirectories.supported, withoutDirectories.directoryRead]).toEqual([true, false]); + }); + + it('also requires resources under enforceStrictCapabilities', async () => { + const bare = { extensions: { [SKILLS_EXTENSION_ID]: {} } }; + const lenient = await scriptedClient(bare, {}); + expect(new SkillsClient(lenient.client).supported).toBe(true); + const strict = await scriptedClient(bare, {}, { enforceStrictCapabilities: true }); + expect(new SkillsClient(strict.client).supported).toBe(false); + }); +}); + +describe('SkillsClient.list', () => { + it('walks every page and keeps the first page caching fields', async () => { + const entries = await threeSkills(); + const { client, calls } = await scriptedClient(CAPABILITIES, skillsHandlers(entries, new Map(), { pageSize: 1 })); + const listing = await new SkillsClient(client).list(); + expect(listing).toEqual({ skills: entries, invalid: [], ttlMs: 60_000, cacheScope: 'public' }); + expect(listing).not.toHaveProperty('resultType'); + expect(methodCalls(calls, 'skills/list')).toHaveLength(3); + }); + + it('returns one page when given a cursor', async () => { + const entries = await threeSkills(); + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers(entries, new Map(), { pageSize: 1 })); + expect(await new SkillsClient(client).list({ cursor: '1' })).toMatchObject({ skills: [entries[1]], nextCursor: '2' }); + }); + + it('with skipInvalid, moves invalid entries, and every copy of a repeated URI, into invalid', async () => { + const [alpha, beta, gamma] = await threeSkills(); + const misnamed = { ...beta!, frontmatter: { ...beta!.frontmatter, name: 'other' } }; + const escaping = { + ...gamma!, + resources: [...(gamma!.resources as object[]), { uri: 'skill://gamma/..%2Fevil/x.md', digest: DIGEST, size: 1 }] + }; + const handlers: Record = { 'skills/list': () => ({ skills: [alpha, misnamed, escaping, { uri: 42 }, alpha] }) }; + const { client } = await scriptedClient(CAPABILITIES, handlers); + const { skills, invalid } = await new SkillsClient(client, { skipInvalid: true }).list(); + expect(skills).toEqual([]); + expect(invalid).toEqual([ + { index: 0, uri: alpha!.uri, reason: expect.stringMatching(/more than once/) }, + { index: 1, uri: beta!.uri, reason: expect.stringMatching(/last path segment/) }, + { index: 2, uri: gamma!.uri, reason: expect.stringMatching(/outside the skill directory/) }, + { index: 3, reason: expect.any(String) }, + { index: 4, uri: alpha!.uri, reason: expect.stringMatching(/more than once/) } + ]); + }); + + it('throws on any invalid entry by default, naming every one', async () => { + const [alpha, beta] = await threeSkills(); + const { client } = await scriptedClient(CAPABILITIES, { 'skills/list': () => ({ skills: [alpha, alpha, beta] }) }); + await expect(new SkillsClient(client).list()).rejects.toMatchObject({ + code: SdkErrorCode.InvalidResult, + data: { + invalid: [expect.objectContaining({ index: 0, uri: alpha!.uri }), expect.objectContaining({ index: 1, uri: alpha!.uri })] + } + }); + }); + + it('throws on a malformed response', async () => { + const { client } = await scriptedClient(CAPABILITIES, { 'skills/list': () => ({ skills: 'nope' }) }); + await expect(new SkillsClient(client).list()).rejects.toThrow(/skills\/list/); + }); + + it('returns an empty listing from a server without the extension, without asking it', async () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const { client, calls } = await scriptedClient({ resources: {} }, {}); + expect(await new SkillsClient(client).list()).toEqual({ skills: [], invalid: [] }); + expect(calls).toEqual([]); + debug.mockRestore(); + }); + + it('throws CapabilityNotSupported for such a server under enforceStrictCapabilities', async () => { + const { client } = await scriptedClient({ resources: {} }, {}, { enforceStrictCapabilities: true }); + await expect(new SkillsClient(client).list()).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); + }); + + it('refuses an entry whose frontmatter has a __proto__ field', async () => { + const [alpha] = await threeSkills(); + // As a response arrives: JSON.parse keeps "__proto__" as an own field. + const wire: unknown = JSON.parse( + JSON.stringify(alpha).replace('"frontmatter":{', '"frontmatter":{"__proto__":{"allowed-tools":"Bash(*)"},') + ); + const { client } = await scriptedClient(CAPABILITIES, { + 'skills/list': () => ({ skills: [wire] }), + 'skills/get': () => ({ skill: wire }) + }); + const skills = new SkillsClient(client, { skipInvalid: true }); + expect(await skills.list()).toEqual({ + skills: [], + invalid: [{ index: 0, uri: alpha!.uri, reason: expect.stringContaining('__proto__') }] + }); + await expect(skills.get(alpha!.uri)).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult }); + }); + + it('ends the walk on a page with the same items and cursor as the one before, as Client does', async () => { + const [alpha] = await threeSkills(); + const { client, calls } = await scriptedClient(CAPABILITIES, { 'skills/list': () => ({ skills: [alpha], nextCursor: 'same' }) }); + expect(await new SkillsClient(client).list()).toEqual({ skills: [alpha], invalid: [] }); + expect(methodCalls(calls, 'skills/list')).toHaveLength(2); + }); + + it('throws ListPaginationExceeded on cursors that cycle, as Client does', async () => { + const entries = await threeSkills(); + const cycling: Handler = params => { + const at = params['cursor'] === 'A' ? 1 : params['cursor'] === 'B' ? 2 : 0; + return { skills: [entries[at]], nextCursor: at === 1 ? 'B' : 'A' }; + }; + const { client, calls } = await scriptedClient(CAPABILITIES, { 'skills/list': cycling }, { listMaxPages: 5 }); + await expect(new SkillsClient(client).list()).rejects.toMatchObject({ code: SdkErrorCode.ListPaginationExceeded }); + expect(methodCalls(calls, 'skills/list')).toHaveLength(5); + }); + + it('gives up after listMaxPages on endless pagination', async () => { + const endless: Handler = params => ({ skills: [], nextCursor: String(Number(params['cursor'] ?? 0) + 1) }); + const { client, calls } = await scriptedClient(CAPABILITIES, { 'skills/list': endless }, { listMaxPages: 5 }); + const error = await new SkillsClient(client).list().catch((error: unknown) => error); + expect(error).toBeInstanceOf(SdkError); + expect(error).toMatchObject({ code: SdkErrorCode.ListPaginationExceeded }); + expect(methodCalls(calls, 'skills/list')).toHaveLength(5); + }); +}); + +describe('SkillsClient.get', () => { + it('returns the entry', async () => { + const [alpha] = await threeSkills(); + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers([alpha!], new Map())); + expect(await new SkillsClient(client).get(alpha!.uri)).toEqual(alpha); + }); + + it('passes server errors through', async () => { + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers([], new Map())); + await expect(new SkillsClient(client).get('skill://nope/SKILL.md')).rejects.toMatchObject({ code: -32_602 }); + }); + + it('throws CapabilityNotSupported on a server without the extension', async () => { + const { client } = await scriptedClient({ resources: {} }, {}); + await expect(new SkillsClient(client).get('skill://alpha/SKILL.md')).rejects.toMatchObject({ + code: SdkErrorCode.CapabilityNotSupported + }); + }); + + it('throws when the server returns another skill or an invalid entry', async () => { + const [alpha, beta] = await threeSkills(); + const other = await scriptedClient(CAPABILITIES, { 'skills/get': () => ({ skill: beta }) }); + await expect(new SkillsClient(other.client).get(alpha!.uri)).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult }); + const invalid = { ...alpha!, frontmatter: { ...alpha!.frontmatter, name: 'Not Valid' } }; + const broken = await scriptedClient(CAPABILITIES, { 'skills/get': () => ({ skill: invalid }) }); + await expect(new SkillsClient(broken.client).get(alpha!.uri)).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult }); + }); +}); + +describe('SkillsClient.readDirectory', () => { + const references = { uri: `${PDF_ROOT}/references`, name: 'references', mimeType: 'inode/directory' }; + const manifest = { uri: `${PDF_ROOT}/SKILL.md`, name: 'SKILL.md', mimeType: 'text/markdown', size: 10 }; + + it('walks every page', async () => { + const pages: Handler = params => + params['cursor'] === undefined + ? { resources: [manifest], nextCursor: 'p2', ttlMs: 0, cacheScope: 'private' } + : { resources: [references], ttlMs: 0, cacheScope: 'private' }; + const { client, calls } = await scriptedClient(CAPABILITIES, { 'resources/directory/read': pages }); + expect(await new SkillsClient(client).readDirectory(PDF_ROOT)).toEqual([manifest, references]); + expect(methodCalls(calls, 'resources/directory/read').map(call => call.params['uri'])).toEqual([PDF_ROOT, PDF_ROOT]); + }); + + it('throws CapabilityNotSupported without directoryRead', async () => { + const { client } = await scriptedClient(NO_DIRECTORIES, {}); + await expect(new SkillsClient(client).readDirectory(PDF_ROOT)).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); + }); + + it('passes server errors through', async () => { + const missing: Handler = () => { + throw new RpcError(-32_602, 'not a directory'); + }; + const { client } = await scriptedClient(CAPABILITIES, { 'resources/directory/read': missing }); + await expect(new SkillsClient(client).readDirectory(`${PDF_ROOT}/SKILL.md`)).rejects.toMatchObject({ code: -32_602 }); + }); +}); diff --git a/packages/client/test/ext/skills/verify.test.ts b/packages/client/test/ext/skills/verify.test.ts new file mode 100644 index 0000000000..480aacc0b3 --- /dev/null +++ b/packages/client/test/ext/skills/verify.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { frontmatterDifferences } from '../../../src/ext/skills/verify'; + +describe('frontmatterDifferences', () => { + it('accepts equal values, objects in any key order', () => { + expect(frontmatterDifferences({ name: 'a', metadata: { x: '1', y: '2' } }, { metadata: { y: '2', x: '1' }, name: 'a' })).toEqual( + [] + ); + }); + + it('names every differing field, both ways', () => { + expect(frontmatterDifferences({ name: 'a', extra: 'e' }, { name: 'b', other: 'o' })).toEqual([ + 'extra: the entry has "e", SKILL.md has nothing', + 'name: the entry has "a", SKILL.md has "b"', + 'other: the entry has nothing, SKILL.md has "o"' + ]); + }); + + it('compares arrays in order and never coerces types', () => { + expect(frontmatterDifferences({ tags: ['a', 'b'] }, { tags: ['b', 'a'] })).toHaveLength(2); + expect(frontmatterDifferences({ tags: ['a'] }, { tags: ['a', 'b'] })).toEqual(['tags: the entry has 1 items, SKILL.md has 2']); + expect(frontmatterDifferences({ version: 1 }, { version: '1' })).toEqual(['version: the entry has 1, SKILL.md has "1"']); + expect(frontmatterDifferences({ value: null }, { value: false })).toHaveLength(1); + expect(frontmatterDifferences({ n: 1 }, { n: 1 })).toEqual([]); + }); + + it('reports nested paths', () => { + expect(frontmatterDifferences({ metadata: { author: 'a' } }, { metadata: { author: 'b' } })).toEqual([ + 'metadata.author: the entry has "a", SKILL.md has "b"' + ]); + }); + + it('reads own keys only, never what Object.prototype supplies', () => { + expect(frontmatterDifferences({}, { constructor: 'x' })).toEqual(['constructor: the entry has nothing, SKILL.md has "x"']); + // JSON.parse makes "__proto__" an own key; a lookup through the prototype would see Object.prototype, an empty object. + expect(frontmatterDifferences(JSON.parse('{"__proto__":{}}'), {})).toEqual(['__proto__: the entry has {}, SKILL.md has nothing']); + }); + + it('sees fields inherited through a prototype, as a reader of the object does', () => { + expect(frontmatterDifferences(Object.create({ 'allowed-tools': 'Bash(*)' }), {})).toEqual([ + 'allowed-tools: the entry has "Bash(*)", SKILL.md has nothing' + ]); + expect(frontmatterDifferences({ a: { b: 1 } }, { a: Object.create({ b: 1, c: 2 }) })).toEqual([ + 'a.c: the entry has nothing, SKILL.md has 2' + ]); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8fc1de9347..4fb474f8fb 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -7,9 +7,13 @@ "*": ["./*"], "@modelcontextprotocol/core-internal": ["./node_modules/@modelcontextprotocol/core-internal/src/index.ts"], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], + "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/validators/ajv": [ "./node_modules/@modelcontextprotocol/core-internal/src/validators/ajvProvider.ts" ], diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 1a1229fedf..d4a8de998a 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -9,7 +9,8 @@ export default defineConfig({ 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', 'src/validators/ajv.ts', - 'src/validators/cfWorker.ts' + 'src/validators/cfWorker.ts', + 'src/ext/skills/index.ts' ], format: ['esm', 'cjs'], fixedExtension: true, @@ -26,8 +27,10 @@ export default defineConfig({ baseUrl: '.', paths: { 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.ts'], + '@modelcontextprotocol/core/ext/skills': ['../core/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], + '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } @@ -37,5 +40,5 @@ export default defineConfig({ // The schema modules live in @modelcontextprotocol/core (a real runtime dependency); the // bundled core-internal shims import them via the './internal' subpath, which must stay an // external import (explicit entry — the tsconfig paths alias would otherwise inline it). - external: ['@modelcontextprotocol/client/_shims', '@modelcontextprotocol/core/internal'] + external: ['@modelcontextprotocol/client/_shims', '@modelcontextprotocol/core/internal', '@modelcontextprotocol/core/ext/skills'] }); diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 86b79e20b9..23d5d1761c 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -33,6 +33,10 @@ "types": "./src/exports/public/index.ts", "import": "./src/exports/public/index.ts" }, + "./ext/skills": { + "types": "./src/ext/skills/index.ts", + "import": "./src/ext/skills/index.ts" + }, "./validators/ajv": { "types": "./src/validators/ajvProvider.ts", "import": "./src/validators/ajvProvider.ts" @@ -97,6 +101,7 @@ "prettier": "catalog:devTools", "typescript": "catalog:devTools", "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "vitest": "catalog:devTools", + "yaml": "^2.8.3" } } diff --git a/packages/core-internal/src/ext/skills/bytes.ts b/packages/core-internal/src/ext/skills/bytes.ts new file mode 100644 index 0000000000..cb8e118767 --- /dev/null +++ b/packages/core-internal/src/ext/skills/bytes.ts @@ -0,0 +1,44 @@ +const encoder = new TextEncoder(); + +/** UTF-8 bytes of `text`. */ +export function encodeUtf8(text: string): Uint8Array { + return encoder.encode(text); +} + +/** Decodes UTF-8 only if decoding loses nothing (a BOM is kept); otherwise `undefined`. */ +export function decodeUtf8Lossless(bytes: Uint8Array): string | undefined { + try { + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); + } catch { + return undefined; + } +} + +/** Standard base64 of `bytes`. */ +export function encodeBase64(bytes: Uint8Array): string { + let binary = ''; + const chunk = 0x80_00; + for (let offset = 0; offset < bytes.length; offset += chunk) { + binary += String.fromCodePoint(...bytes.subarray(offset, offset + chunk)); + } + return btoa(binary); +} + +/** Bytes of a standard base64 string. */ +export function decodeBase64(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) bytes[index] = binary.codePointAt(index)!; + return bytes; +} + +/** `sha256:` followed by the lowercase hex SHA-256 of `bytes` (WebCrypto, every runtime). */ +export async function sha256Digest(bytes: Uint8Array): Promise { + const { buffer, byteOffset, byteLength } = bytes; + // WebCrypto refuses views over a SharedArrayBuffer; only those are copied. + const view = buffer instanceof ArrayBuffer ? new Uint8Array(buffer, byteOffset, byteLength) : new Uint8Array(bytes); + const hash = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', view)); + let hex = ''; + for (const byte of hash) hex += byte.toString(16).padStart(2, '0'); + return `sha256:${hex}`; +} diff --git a/packages/core-internal/src/ext/skills/entry.ts b/packages/core-internal/src/ext/skills/entry.ts new file mode 100644 index 0000000000..c9b4117ea0 --- /dev/null +++ b/packages/core-internal/src/ext/skills/entry.ts @@ -0,0 +1,110 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SkillSchema } from '@modelcontextprotocol/core/ext/skills'; + +import { decodeUtf8Lossless, sha256Digest } from './bytes'; +import { FrontmatterError, parseSkillMarkdown } from './frontmatter'; +import { canonicalUri, compareUris, isWithin, lastSegmentOf, skillFileKey, skillRootUri } from './uri'; + +/** Agent Skills name rule: lowercase letters, digits and single hyphens (length checked separately). */ +export const SKILL_NAME_PATTERN = /^[\da-z]+(?:-[\da-z]+)*$/; + +const isStringMap = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.values(value as Record).every(item => typeof item === 'string'); + +// Agent Skills length limits count characters (code points), not UTF-16 code units. +function countCharacters(text: string): number { + let count = 0; + for (let index = 0; index < text.length; index += text.codePointAt(index)! > 0xff_ff ? 2 : 1) count++; + return count; +} + +/** Structural rules of the stable spec and Agent Skills. Empty array = valid. */ +export function validateSkillEntry(skill: Skill): string[] { + const problems: string[] = []; + const root = skillRootUri(skill.uri); + if (root === undefined) return [`uri "${skill.uri}" must be a URI ending in /SKILL.md inside a named directory`]; + const frontmatter = skill.frontmatter as Record; + const { name, description } = skill.frontmatter; + if (name.length === 0 || name.length > 64 || !SKILL_NAME_PATTERN.test(name)) { + problems.push(`frontmatter name "${name}" must be 1-64 lowercase letters, digits and single hyphens`); + } else if (lastSegmentOf(root) !== name) { + problems.push(`frontmatter name "${name}" must match the last path segment "${lastSegmentOf(root)}"`); + } + if (description === '' || countCharacters(description) > 1024) problems.push('frontmatter description must be 1-1024 characters'); + const compatibility = frontmatter['compatibility']; + if ( + compatibility !== undefined && + (typeof compatibility !== 'string' || compatibility === '' || countCharacters(compatibility) > 500) + ) { + problems.push('frontmatter compatibility must be a string of 1-500 characters'); + } + if (frontmatter['metadata'] !== undefined && !isStringMap(frontmatter['metadata'])) { + problems.push('frontmatter metadata must map strings to strings'); + } + if (skill.resources !== 'dynamic') { + const self = canonicalUri(skill.uri); + const seen = new Set(); + let hasSelf = false; + for (const resource of skill.resources) { + const canonical = canonicalUri(resource.uri); + if (canonical === undefined) { + problems.push(`resource "${resource.uri}" is not a valid URI`); + continue; + } + // A canonical URI holds a raw `?` or `#` only as a query or fragment, even an empty one. + if (/[#?]/.test(canonical)) { + problems.push(`resource "${resource.uri}" must not have a query or fragment`); + continue; + } + const key = skillFileKey(root, canonical) ?? canonical; + if (seen.has(key)) problems.push(`resource "${resource.uri}" is listed more than once`); + seen.add(key); + if (canonical === self) hasSelf = true; + else if (!isWithin(root, resource.uri)) problems.push(`resource "${resource.uri}" is outside the skill directory ${root}`); + } + if (!hasSelf) problems.push(`resources must include ${skill.uri}`); + } + return problems; +} + +/** Schema check plus {@linkcode validateSkillEntry}, for values received from elsewhere. */ +export function skillEntryProblems(value: unknown): string[] { + const parsed = SkillSchema.safeParse(value); + if (!parsed.success) return parsed.error.issues.map(issue => `${issue.path.join('.') || '(entry)'}: ${issue.message}`); + return validateSkillEntry(parsed.data); +} + +export interface SkillFileInput { + uri: string; + bytes: Uint8Array; +} + +/** Builds an entry from a skill's files: parses `SKILL.md`, hashes every file, sorts by URI, validates. */ +export async function buildSkillEntry( + skillMdUri: string, + files: readonly SkillFileInput[] +): Promise<{ skill?: Skill; problems: string[] }> { + const manifest = files.find(file => file.uri === skillMdUri); + if (manifest === undefined) return { problems: ['SKILL.md is missing'] }; + const text = decodeUtf8Lossless(manifest.bytes); + if (text === undefined) return { problems: ['SKILL.md is not valid UTF-8'] }; + let frontmatter: Record; + try { + frontmatter = parseSkillMarkdown(text).frontmatter; + } catch (error) { + if (error instanceof FrontmatterError) return { problems: [`SKILL.md frontmatter: ${error.message}`] }; + throw error; + } + if (typeof frontmatter['name'] !== 'string' || typeof frontmatter['description'] !== 'string') { + return { problems: ['SKILL.md frontmatter must contain string "name" and "description"'] }; + } + const resources = await Promise.all( + files.map(async file => ({ uri: file.uri, digest: await sha256Digest(file.bytes), size: file.bytes.byteLength })) + ); + resources.sort((a, b) => compareUris(a.uri, b.uri)); + const skill = { uri: skillMdUri, frontmatter, resources } as Skill; + return { skill, problems: validateSkillEntry(skill) }; +} diff --git a/packages/core-internal/src/ext/skills/frontmatter.ts b/packages/core-internal/src/ext/skills/frontmatter.ts new file mode 100644 index 0000000000..d5bf00d495 --- /dev/null +++ b/packages/core-internal/src/ext/skills/frontmatter.ts @@ -0,0 +1,544 @@ +/** + * Frontmatter reader for `SKILL.md` (SEP-2640). + * + * Reads the YAML subset real skills use — block mappings and sequences; + * plain, quoted and block scalars; one-line flow collections; comments — with + * YAML 1.2 core-schema typing. Anything outside the subset throws a + * {@linkcode FrontmatterError} instead of being read differently from a full + * YAML parser. Server catalogs and client verification both call it; it is + * kept behind this module so it can be swapped for a YAML library. + */ + +export class FrontmatterError extends Error { + readonly line: number | undefined; + + constructor(message: string, line?: number) { + super(line === undefined ? message : `line ${line}: ${message}`); + this.name = 'FrontmatterError'; + this.line = line; + } +} + +export interface SplitSkillMarkdown { + /** The YAML between the opening and closing `---` lines, with LF line endings. */ + yaml: string; + /** The markdown after the closing `---` line. */ + body: string; +} + +export interface ParsedSkillMarkdown { + frontmatter: Record; + body: string; +} + +const MAX_DEPTH = 32; + +/** Splits `SKILL.md` into its frontmatter YAML and markdown body. */ +export function splitFrontmatter(markdown: string): SplitSkillMarkdown { + let text = markdown.replaceAll('\r\n', '\n'); + if (text.codePointAt(0) === 0xfe_ff) text = text.slice(1); + if (!text.startsWith('---\n')) throw new FrontmatterError('SKILL.md must begin with a "---" line', 1); + const rest = text.slice(4); + const close = /^---[\t ]*$/m.exec(rest); + if (close === null) throw new FrontmatterError('frontmatter has no closing "---" line'); + let body = rest.slice(close.index + close[0].length); + if (body.startsWith('\n')) body = body.slice(1); + return { yaml: rest.slice(0, close.index), body }; +} + +/** Splits and parses `SKILL.md`. Line numbers in errors count from the file's first line. */ +export function parseSkillMarkdown(markdown: string): ParsedSkillMarkdown { + const { yaml, body } = splitFrontmatter(markdown); + return { frontmatter: parseFrontmatterYaml(yaml, 2), body }; +} + +/** Parses frontmatter YAML into a JSON object. `firstLine` numbers the first YAML line in errors. */ +export function parseFrontmatterYaml(yaml: string, firstLine = 1): Record { + return new Parser(toLines(yaml, firstLine)).parseDocument(); +} + +interface Line { + readonly no: number; + // Leading spaces only: a tab ends the indentation. + readonly indent: number; + // The line after its leading spaces and tabs. + readonly text: string; + readonly raw: string; +} + +function toLines(yaml: string, firstLine: number): Line[] { + const raws = yaml.split('\n'); + if (raws.at(-1) === '') raws.pop(); + return raws.map((raw, index) => ({ no: firstLine + index, indent: /^ */.exec(raw)?.[0].length ?? 0, text: trimWhiteStart(raw), raw })); +} + +const TAB_INDENT = 'tabs cannot be used for indentation'; + +const isSkippable = (line: Line): boolean => line.text === '' || line.text.startsWith('#'); +const isTabIndented = (line: Line): boolean => line.raw[line.indent] === '\t'; +const isSequenceItem = (text: string): boolean => /^-(?:[\t ]|$)/.test(text); + +class Parser { + private index = 0; + + constructor(private readonly lines: Line[]) {} + + parseDocument(): Record { + const first = this.peek(); + if (first === undefined) return {}; + // YAML drops a BOM before the first content, so reading it into the first key would differ: fail closed. + if (first.text.startsWith('\uFEFF')) { + throw new FrontmatterError('a byte order mark inside the frontmatter is not supported', first.no); + } + if (isSequenceItem(first.text) || matchKey(first.text, first.no) === undefined) { + throw new FrontmatterError('frontmatter must be a mapping of keys to values', first.no); + } + const map = this.parseMapping(first.indent, 0); + const extra = this.peek(); + if (extra !== undefined) throw new FrontmatterError('unexpected content', extra.no); + return map; + } + + private peek(): Line | undefined { + while (this.index < this.lines.length && isSkippable(this.lines[this.index]!)) this.index++; + const line = this.lines[this.index]; + if (line !== undefined && isTabIndented(line)) throw new FrontmatterError(TAB_INDENT, line.no); + return line; + } + + private parseMapping(indent: number, depth: number): Record { + assertDepth(depth, this.lines[this.index]?.no); + const map: Record = {}; + for (let line = this.peek(); line !== undefined && line.indent >= indent; line = this.peek()) { + if (line.indent > indent) throw new FrontmatterError('unexpected indentation', line.no); + if (isSequenceItem(line.text)) break; + const match = matchKey(line.text, line.no); + if (match === undefined) throw new FrontmatterError('expected "key: value"', line.no); + if (Object.hasOwn(map, match.key)) throw new FrontmatterError(`duplicate key "${match.key}"`, line.no); + this.index++; + map[match.key] = this.parseValue(match.rest, line, indent, depth + 1, true); + } + return map; + } + + private parseSequence(indent: number, depth: number): unknown[] { + assertDepth(depth, this.lines[this.index]?.no); + const items: unknown[] = []; + for (let line = this.peek(); line !== undefined && line.indent >= indent; line = this.peek()) { + if (line.indent > indent) throw new FrontmatterError('unexpected indentation', line.no); + if (!isSequenceItem(line.text)) break; + const after = line.text.slice(1); + const rest = trimWhiteStart(after); + const column = line.indent + 1 + (after.length - rest.length); + if (rest !== '' && !rest.startsWith('#') && (isSequenceItem(rest) || matchKey(rest, line.no) !== undefined)) { + // `- key: value` or `- - item`: the item is a block node that starts at `column`. + if (/^ *\t/.test(after)) throw new FrontmatterError(TAB_INDENT, line.no); + this.lines[this.index] = { ...line, indent: column, text: rest }; + items.push(isSequenceItem(rest) ? this.parseSequence(column, depth + 1) : this.parseMapping(column, depth + 1)); + continue; + } + this.index++; + items.push(this.parseValue(rest, line, indent, depth + 1, false)); + } + return items; + } + + private parseValue(rest: string, line: Line, parentIndent: number, depth: number, allowCompactSequence: boolean): unknown { + if (rest === '' || rest.startsWith('#')) { + const next = this.peek(); + if (next !== undefined && next.indent > parentIndent) return this.parseIndented(parentIndent, depth); + if (allowCompactSequence && next !== undefined && next.indent === parentIndent && isSequenceItem(next.text)) { + return this.parseSequence(parentIndent, depth); + } + return null; + } + const first = rest[0]!; + if (first === '|' || first === '>') return this.parseBlockScalar(rest, line, parentIndent); + if (first === '"' || first === "'") { + const { value, end } = readQuoted(rest, 0, line.no); + expectOnlyComment(rest.slice(end), line.no); + return value; + } + if (first === '[' || first === '{') { + const { value, end } = readFlow(rest, 0, line.no, depth); + expectOnlyComment(rest.slice(end), line.no); + return value; + } + if ('&*!'.includes(first)) throw new FrontmatterError('anchors, aliases and tags are not supported', line.no); + if ('%@`,]}'.includes(first)) throw new FrontmatterError(`"${first}" cannot start a plain value; quote it`, line.no); + if (isSequenceItem(rest) || /^\?(?:[\t ]|$)/.test(rest)) { + throw new FrontmatterError('a nested collection must start on its own line', line.no); + } + return this.parsePlain(rest, line, parentIndent); + } + + private parseIndented(parentIndent: number, depth: number): unknown { + const line = this.peek()!; + if (isSequenceItem(line.text)) return this.parseSequence(line.indent, depth); + if (matchKey(line.text, line.no) !== undefined) return this.parseMapping(line.indent, depth); + this.index++; + return this.parseValue(line.text, line, parentIndent, depth, false); + } + + private parsePlain(first: string, line: Line, parentIndent: number): unknown { + let value = stripComment(first); + assertPlain(value, line.no); + let commented = COMMENT.test(first); + let blanks = 0; + while (this.index < this.lines.length) { + const next = this.lines[this.index]!; + if (next.text === '') { + blanks++; + this.index++; + continue; + } + if (next.indent <= parentIndent || next.text.startsWith('#')) break; + if (isTabIndented(next)) throw new FrontmatterError(TAB_INDENT, next.no); + if (commented) throw new FrontmatterError('a comment ends a plain value; quote a value that continues past it', next.no); + const part = stripComment(next.text); + assertPlain(part, next.no); + commented = COMMENT.test(next.text); + value += blanks > 0 ? '\n'.repeat(blanks) : ' '; + value += part; + blanks = 0; + this.index++; + } + return resolvePlain(value, line.no); + } + + private parseBlockScalar(header: string, line: Line, parentIndent: number): string { + const match = /^([>|])([+-]?)(?:[\t ]+#.*)?[\t ]*$/.exec(header); + if (match === null) { + const indicator = /^[>|][+-]?\d|^[>|]\d/.test(header); + throw new FrontmatterError( + indicator ? 'block scalar indentation indicators are not supported' : 'invalid block scalar header', + line.no + ); + } + const contents: string[] = []; + let contentIndent = -1; + let leadingWidth = 0; + while (this.index < this.lines.length) { + const next = this.lines[this.index]!; + const spacesOnly = next.indent === next.raw.length; + if (contentIndent === -1 && !spacesOnly && next.indent > parentIndent) { + contentIndent = next.indent; + if (leadingWidth > contentIndent) { + throw new FrontmatterError('a leading blank line is indented more than the block scalar content', next.no); + } + } + if (contentIndent !== -1 && next.indent >= contentIndent) { + contents.push(next.raw.slice(contentIndent)); + } else if (spacesOnly) { + if (contentIndent === -1) leadingWidth = Math.max(leadingWidth, next.indent); + contents.push(''); + } else if (isTabIndented(next)) { + throw new FrontmatterError(TAB_INDENT, next.no); + } else { + break; + } + this.index++; + } + return foldBlock(contents, match[1] === '>', match[2] ?? ''); + } +} + +function assertDepth(depth: number, line: number | undefined): void { + if (depth > MAX_DEPTH) throw new FrontmatterError(`nesting deeper than ${MAX_DEPTH} levels is not supported`, line); +} + +interface KeyMatch { + key: string; + rest: string; +} + +function matchKey(text: string, no: number): KeyMatch | undefined { + const first = text[0]; + if (first === '"' || first === "'") { + const { value, end } = readQuoted(text, 0, no); + const after = text.slice(end); + if (after === ':' || /^:[\t ]/.test(after)) return { key: checkKey(value, no), rest: trimWhiteStart(after.slice(1)) }; + return undefined; + } + if (first === '?') throw new FrontmatterError('complex keys ("?") are not supported', no); + if (first === undefined || '[]{}&*!|>%@`#,'.includes(first)) return undefined; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + const previous = text[index - 1]; + if (char === '#' && (previous === ' ' || previous === '\t')) return undefined; + if (char === ':') { + const next = text[index + 1]; + if (next === undefined || next === ' ' || next === '\t') { + const key = trimWhiteEnd(text.slice(0, index)); + if (key === '') return undefined; + return { key: resolveKey(key, no), rest: trimWhiteStart(text.slice(index + 1)) }; + } + } + } + return undefined; +} + +function checkKey(key: string, no: number): string { + if (key === '__proto__') throw new FrontmatterError('the key "__proto__" is not supported', no); + return key; +} + +function resolveKey(raw: string, no: number): string { + if (typeof resolvePlain(raw, no) !== 'string') throw new FrontmatterError(`key "${raw}" must be a string; quote it`, no); + return checkKey(raw, no); +} + +function assertPlain(text: string, no: number): void { + if (/:(?:[\t ]|$)/.test(text)) throw new FrontmatterError('a plain value cannot contain ": "; quote the value', no); +} + +const COMMENT = /[\t ]#/; + +function stripComment(text: string): string { + const match = COMMENT.exec(text); + return trimWhiteEnd(match === null ? text : text.slice(0, match.index)); +} + +function expectOnlyComment(after: string, no: number): void { + if (trimWhiteStart(after) === '' || /^[\t ]+#/.test(after)) return; + throw new FrontmatterError('unexpected text after the value', no); +} + +// YAML white space is only space and tab; `String.prototype.trim` would also strip Unicode spaces YAML keeps. +function trimWhiteStart(text: string): string { + return text.slice(skipSpaces(text, 0)); +} + +function trimWhiteEnd(text: string): string { + let end = text.length; + while (end > 0 && (text[end - 1] === ' ' || text[end - 1] === '\t')) end--; + return text.slice(0, end); +} + +const NULL = /^(?:~|null|Null|NULL)$/; +const TRUE = /^(?:true|True|TRUE)$/; +const FALSE = /^(?:false|False|FALSE)$/; +const INTEGER = /^[+-]?\d+$/; +const OCTAL = /^0o[0-7]+$/; +const HEXADECIMAL = /^0x[\dA-Fa-f]+$/; +const FLOAT = /^[+-]?(?:\.\d+|\d+(?:\.\d*)?)(?:[Ee][+-]?\d+)?$/; +const SPECIAL_FLOAT = /^(?:[+-]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/; + +function resolvePlain(text: string, no: number): unknown { + if (text === '' || NULL.test(text)) return null; + if (TRUE.test(text)) return true; + if (FALSE.test(text)) return false; + let number: number | undefined; + if (INTEGER.test(text) || FLOAT.test(text)) number = Number(text); + else if (OCTAL.test(text)) number = Number.parseInt(text.slice(2), 8); + else if (HEXADECIMAL.test(text)) number = Number.parseInt(text.slice(2), 16); + else if (SPECIAL_FLOAT.test(text)) number = Number.NaN; + if (number === undefined) return text; + if (!Number.isFinite(number)) throw new FrontmatterError(`"${text}" cannot be represented in JSON; quote it`, no); + return number; +} + +const SIMPLE_ESCAPES: Readonly> = { + '0': '\0', + a: '\u0007', + b: '\b', + t: '\t', + '\t': '\t', + n: '\n', + v: '\v', + f: '\f', + r: '\r', + e: '\u001B', + ' ': ' ', + '"': '"', + '/': '/', + '\\': '\\', + N: '\u0085', + _: '\u00A0', + L: '\u2028', + P: '\u2029' +}; + +const HEX_ESCAPE_WIDTH: Readonly> = { x: 2, u: 4, U: 8 }; + +const UNCLOSED_QUOTE = 'a quoted value must close on the same line (multi-line quoted values are not supported)'; + +function readQuoted(text: string, start: number, no: number): { value: string; end: number } { + const quote = text[start]; + let value = ''; + let index = start + 1; + while (index < text.length) { + const char = text[index]!; + if (quote === "'") { + if (char === "'") { + if (text[index + 1] === "'") { + value += "'"; + index += 2; + continue; + } + return { value, end: index + 1 }; + } + value += char; + index++; + continue; + } + if (char === '"') return { value, end: index + 1 }; + if (char === '\\') { + const escape = decodeEscape(text, index, no); + value += escape.decoded; + index += escape.length; + continue; + } + value += char; + index++; + } + throw new FrontmatterError(UNCLOSED_QUOTE, no); +} + +function decodeEscape(text: string, index: number, no: number): { decoded: string; length: number } { + const code = text[index + 1]; + if (code === undefined) throw new FrontmatterError(UNCLOSED_QUOTE, no); + const simple = SIMPLE_ESCAPES[code]; + if (simple !== undefined) return { decoded: simple, length: 2 }; + const width = HEX_ESCAPE_WIDTH[code]; + if (width === undefined) throw new FrontmatterError(`unknown escape "\\${code}"`, no); + const hex = text.slice(index + 2, index + 2 + width); + if (hex.length !== width || !/^[\dA-Fa-f]+$/.test(hex) || Number.parseInt(hex, 16) > 0x10_ff_ff) { + throw new FrontmatterError(`invalid escape "\\${code}${hex}"`, no); + } + return { decoded: String.fromCodePoint(Number.parseInt(hex, 16)), length: 2 + width }; +} + +function skipSpaces(text: string, index: number): number { + let position = index; + while (text[position] === ' ' || text[position] === '\t') position++; + return position; +} + +const UNCLOSED_FLOW = 'a flow collection must close on the same line'; + +function readFlow(text: string, start: number, no: number, depth: number): { value: unknown; end: number } { + assertDepth(depth, no); + const open = text[start]; + const close = open === '[' ? ']' : '}'; + const list: unknown[] = []; + const map: Record = {}; + let index = skipSpaces(text, start + 1); + if (text[index] === close) return { value: open === '[' ? list : map, end: index + 1 }; + for (;;) { + if (open === '[') { + const item = readFlowValue(text, index, no, depth); + list.push(item.value); + index = skipSpaces(text, item.end); + } else { + const key = readFlowKey(text, index, no); + const item = readFlowValue(text, key.end + 1, no, depth); + if (Object.hasOwn(map, key.key)) throw new FrontmatterError(`duplicate key "${key.key}"`, no); + map[key.key] = item.value; + index = skipSpaces(text, item.end); + } + const char = text[index]; + if (char === ',') { + index = skipSpaces(text, index + 1); + if (text[index] === close) return { value: open === '[' ? list : map, end: index + 1 }; + continue; + } + if (char === close) return { value: open === '[' ? list : map, end: index + 1 }; + if (char === undefined) throw new FrontmatterError(UNCLOSED_FLOW, no); + throw new FrontmatterError(`unexpected "${char}" in a flow collection`, no); + } +} + +function readFlowKey(text: string, start: number, no: number): { key: string; end: number } { + const index = skipSpaces(text, start); + const char = text[index]; + if (char === undefined) throw new FrontmatterError(UNCLOSED_FLOW, no); + if (char === '"' || char === "'") { + const quoted = readQuoted(text, index, no); + const colon = skipSpaces(text, quoted.end); + if (text[colon] !== ':') throw new FrontmatterError('expected ": " after a key in a flow mapping', no); + return { key: checkKey(quoted.value, no), end: colon }; + } + if (char === '[' || char === '{') throw new FrontmatterError('complex keys (flow collections as keys) are not supported', no); + assertFlowPlainStart(text, index, no); + const end = scanFlowPlain(text, index); + const raw = trimWhiteEnd(text.slice(index, end)); + const after = text[end + 1]; + if (raw === '' || text[end] !== ':' || (after !== ' ' && after !== '\t')) { + throw new FrontmatterError('expected "key: value" in a flow mapping', no); + } + return { key: resolveKey(raw, no), end }; +} + +// After `-`, `?` or `:`, any of these makes it an indicator in a flow collection. +const FLOW_SEPARATORS = ' \t,[]{}'; + +function assertFlowPlainStart(text: string, index: number, no: number): void { + const char = text[index]!; + if ('&*!'.includes(char)) throw new FrontmatterError('anchors, aliases and tags are not supported', no); + if ('|>%@`#'.includes(char) || ('-?:'.includes(char) && FLOW_SEPARATORS.includes(text[index + 1] ?? ' '))) { + throw new FrontmatterError(`"${char}" cannot start a plain value in a flow collection; quote it`, no); + } +} + +// The end of a plain flow scalar: a flow indicator, a comment or a `:` indicator. +function scanFlowPlain(text: string, index: number): number { + let end = index; + while (end < text.length) { + const char = text[end]!; + if (',[]{}'.includes(char)) break; + if (char === '#' && (text[end - 1] === ' ' || text[end - 1] === '\t')) break; + if (char === ':' && FLOW_SEPARATORS.includes(text[end + 1] ?? ' ')) break; + end++; + } + return end; +} + +function readFlowValue(text: string, start: number, no: number, depth: number): { value: unknown; end: number } { + const index = skipSpaces(text, start); + const char = text[index]; + if (char === undefined) throw new FrontmatterError(UNCLOSED_FLOW, no); + if (char === '[' || char === '{') return readFlow(text, index, no, depth + 1); + if (char === '"' || char === "'") return readQuoted(text, index, no); + assertFlowPlainStart(text, index, no); + const end = scanFlowPlain(text, index); + if (text[end] === ':') throw new FrontmatterError('mappings inside flow collections are not supported; quote the value', no); + const raw = trimWhiteEnd(text.slice(index, end)); + if (raw === '') throw new FrontmatterError('empty value in a flow collection', no); + return { value: resolvePlain(raw, no), end }; +} + +// Joins block scalar lines as YAML does (the algorithm of js-yaml's readBlockScalar). +function foldBlock(contents: readonly string[], folded: boolean, chomping: string): string { + let result = ''; + let emptyLines = 0; + let didReadContent = false; + let atMoreIndented = false; + for (const content of contents) { + if (content === '') { + emptyLines++; + continue; + } + if (folded) { + if (content.startsWith(' ') || content.startsWith('\t')) { + atMoreIndented = true; + result += '\n'.repeat(didReadContent ? 1 + emptyLines : emptyLines); + } else if (atMoreIndented) { + atMoreIndented = false; + result += '\n'.repeat(emptyLines + 1); + } else if (emptyLines === 0) { + if (didReadContent) result += ' '; + } else { + result += '\n'.repeat(emptyLines); + } + } else { + result += '\n'.repeat(didReadContent ? 1 + emptyLines : emptyLines); + } + result += content; + didReadContent = true; + emptyLines = 0; + } + if (chomping === '+') result += '\n'.repeat(didReadContent ? 1 + emptyLines : emptyLines); + else if (chomping === '' && didReadContent) result += '\n'; + return result; +} diff --git a/packages/core-internal/src/ext/skills/index.ts b/packages/core-internal/src/ext/skills/index.ts new file mode 100644 index 0000000000..c62253a065 --- /dev/null +++ b/packages/core-internal/src/ext/skills/index.ts @@ -0,0 +1,26 @@ +// @modelcontextprotocol/core-internal/ext/skills +// +// Internal helpers for the MCP Skills extension (SEP-2640), shared by the +// client and server packages. Not part of any public entry point, and not on +// core-internal's root barrel: import them from this subpath, so they (and the +// `@modelcontextprotocol/core/ext/skills` import in `entry.ts`) stay out of the +// client and server root module graphs. +export { decodeBase64, decodeUtf8Lossless, encodeBase64, encodeUtf8, sha256Digest } from './bytes'; +export type { SkillFileInput } from './entry'; +export { buildSkillEntry, SKILL_NAME_PATTERN, skillEntryProblems, validateSkillEntry } from './entry'; +export type { ParsedSkillMarkdown, SplitSkillMarkdown } from './frontmatter'; +export { FrontmatterError, parseFrontmatterYaml, parseSkillMarkdown, splitFrontmatter } from './frontmatter'; +export { mimeTypeFor } from './mime'; +export { + canonicalUri, + compareUris, + encodeSegment, + isRoundTripUri, + isWithin, + joinUri, + lastSegmentOf, + resolveSkillRef, + skillFileKey, + skillRootUri, + trimTrailingSlashes +} from './uri'; diff --git a/packages/core-internal/src/ext/skills/mime.ts b/packages/core-internal/src/ext/skills/mime.ts new file mode 100644 index 0000000000..36f17c19e7 --- /dev/null +++ b/packages/core-internal/src/ext/skills/mime.ts @@ -0,0 +1,34 @@ +const BY_EXTENSION: Readonly> = { + md: 'text/markdown', + markdown: 'text/markdown', + txt: 'text/plain', + json: 'application/json', + yaml: 'application/yaml', + yml: 'application/yaml', + py: 'text/x-python', + sh: 'application/x-sh', + bash: 'application/x-sh', + js: 'text/javascript', + mjs: 'text/javascript', + ts: 'text/typescript', + html: 'text/html', + css: 'text/css', + csv: 'text/csv', + xml: 'application/xml', + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + pdf: 'application/pdf' +}; + +/** MIME type from the file extension; `application/octet-stream` when unknown. */ +export function mimeTypeFor(fileName: string): string { + const dot = fileName.lastIndexOf('.'); + if (dot <= 0) return 'application/octet-stream'; + const extension = fileName.slice(dot + 1).toLowerCase(); + // Own keys only: an inherited name such as `__proto__` or `constructor` is not a MIME type. + return Object.hasOwn(BY_EXTENSION, extension) ? BY_EXTENSION[extension]! : 'application/octet-stream'; +} diff --git a/packages/core-internal/src/ext/skills/uri.ts b/packages/core-internal/src/ext/skills/uri.ts new file mode 100644 index 0000000000..a60143d234 --- /dev/null +++ b/packages/core-internal/src/ext/skills/uri.ts @@ -0,0 +1,155 @@ +// WHATWG URL's path percent-encode set, `^` included on every runtime (Node 20 and 22 leave it raw), plus `%`, `/` and `\`. +const ENCODED_IN_SEGMENT = /[^\w!$&'()*+,.:;=@[\]|~-]/gu; + +function encodePathText(text: string): string { + return text.replaceAll(ENCODED_IN_SEGMENT, char => encodeURIComponent(char)); +} + +/** Percent-encodes one path segment as WHATWG URL does, plus `%`, `/`, `\` and the dots of `.` and `..`; throws `URIError` on a lone surrogate. */ +export function encodeSegment(segment: string): string { + if (segment === '.' || segment === '..') return segment.replaceAll('.', '%2E'); + return encodePathText(segment); +} + +/** `text` without its trailing `/`s; a loop, since `/\/+$/` backtracks polynomially on a long run of `/`. */ +export function trimTrailingSlashes(text: string): string { + let end = text.length; + while (end > 0 && text.codePointAt(end - 1) === 0x2f) end--; + return text.slice(0, end); +} + +/** `base` (`scheme://`, or a URI without a trailing slash) + `/`-joined encoded segments; an authority is encoded strictly. */ +export function joinUri(base: string, segments: readonly string[]): string { + if (!base.endsWith('://')) return `${trimTrailingSlashes(base)}/${segments.map(segment => encodeSegment(segment)).join('/')}`; + const [authority, ...path] = segments; + if (authority === undefined) return base; + return [base + encodeURIComponent(authority), ...path.map(segment => encodeSegment(segment))].join('/'); +} + +/** WHATWG-serialised form of `uri`, or `undefined` if it does not parse. */ +export function canonicalUri(uri: string): string | undefined { + try { + return new URL(uri).href; + } catch { + return undefined; + } +} + +/** True when `uri` is already in canonical form. */ +export function isRoundTripUri(uri: string): boolean { + return canonicalUri(uri) === uri; +} + +const MANIFEST_SUFFIX = '/SKILL.md'; + +/** The skill's root directory URI (no trailing slash), or `undefined` if `skillMdUri` is not a `…/SKILL.md` URI in a named directory. */ +export function skillRootUri(skillMdUri: string): string | undefined { + const canonical = canonicalUri(skillMdUri); + if (canonical === undefined || !canonical.endsWith(MANIFEST_SUFFIX)) return undefined; + const root = canonical.slice(0, -MANIFEST_SUFFIX.length); + // `scheme:/` or `scheme://` (no directory) and `…//SKILL.md` (an empty last segment) name no directory. + return root.endsWith('/') ? undefined : root; +} + +function safeDecode(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +/** The decoded last segment of a directory URI (the authority when the path is empty). */ +export function lastSegmentOf(rootUri: string): string { + const url = new URL(rootUri); + const path = url.pathname; + return safeDecode(path === '' || path === '/' ? url.host : path.slice(path.lastIndexOf('/') + 1)); +} + +/** True when `uri` is strictly inside `rootUri` and no segment below it decodes to empty, `.`, `..`, or a name holding `/` or NUL. */ +export function isWithin(rootUri: string, uri: string): boolean { + const canonical = canonicalUri(uri); + if (canonical === undefined || !canonical.startsWith(`${rootUri}/`)) return false; + return canonical + .slice(rootUri.length + 1) + .split('/') + .every(segment => { + const decoded = safeDecode(segment); + return decoded !== '' && decoded !== '.' && decoded !== '..' && !decoded.includes('/') && !decoded.includes('\0'); + }); +} + +function decodeOrUndefined(escapes: string): string | undefined { + try { + return decodeURIComponent(escapes); + } catch { + return undefined; + } +} + +function utf8Width(lead: number): number { + if (lead >= 0xf0) return 4; + if (lead >= 0xe0) return 3; + if (lead >= 0xc0) return 2; + return 1; +} + +// Each valid UTF-8 sequence is decoded and re-encoded; a `%XX` starting none is a literal `%`, as published (`10%25Dec.md`). +function keyOfEscapes(run: string): string { + let key = ''; + for (let at = 0; at < run.length; ) { + const width = 3 * utf8Width(Number.parseInt(run.slice(at + 1, at + 3), 16)); + const decoded = decodeOrUndefined(run.slice(at, at + width)); + if (decoded === undefined) { + key += `%25${run.slice(at + 1, at + 3)}`; + at += 3; + } else { + key += encodePathText(decoded); + at += width; + } + } + return key; +} + +// Runs of `%XX` escapes; splitting on this puts them at odd indexes. +const ESCAPE_RUN = /((?:%[\dA-Fa-f]{2})+)/; + +function keyOfSegment(segment: string): string { + return segment + .split(ESCAPE_RUN) + .map((part, index) => (index % 2 === 0 ? encodePathText(part) : keyOfEscapes(part))) + .join(''); +} + +/** The one spelling all ways of writing a file's URI below `rootUri` share (`Q&A.md`, `Q%26A.md#x`); `undefined` if it does not parse. */ +export function skillFileKey(rootUri: string, uri: string): string | undefined { + let url: URL; + try { + url = new URL(uri); + } catch { + return undefined; + } + url.hash = ''; + const canonical = url.href; + // A canonical path never holds a raw `?`, so one here starts a query, even an empty one. + if (canonical.includes('?') || !canonical.startsWith(`${rootUri}/`)) return canonical; + const below = canonical + .slice(rootUri.length + 1) + .split('/') + .map(segment => keyOfSegment(segment)); + return `${rootUri}/${below.join('/')}`; +} + +/** Resolves `ref` (default `SKILL.md`) against the skill's root, as a browser resolves a link. */ +export function resolveSkillRef(skillMdUri: string, ref?: string): string { + const root = skillRootUri(skillMdUri); + if (root === undefined) throw new TypeError(`${skillMdUri} is not a SKILL.md URI`); + return new URL(ref ?? 'SKILL.md', `${root}/`).href; +} + +/** Code-unit order of two URIs: the one order for entries, directory children and cursors. */ +export function compareUris(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 0aa5aae21a..c93a604fa1 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -1,6 +1,7 @@ export * from './auth/errors'; export * from './errors/crossBundleBrand'; export * from './errors/sdkErrors'; +// Skills internals live on the `./ext/skills` subpath, not here, so package roots never load them. export * from './shared/auth'; export * from './shared/authUtils'; export * from './shared/clientCapabilityRequirements'; diff --git a/packages/core-internal/test/ext/skills/bytes.test.ts b/packages/core-internal/test/ext/skills/bytes.test.ts new file mode 100644 index 0000000000..157b3a3d29 --- /dev/null +++ b/packages/core-internal/test/ext/skills/bytes.test.ts @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto'; + +import { describe, expect, it, vi } from 'vitest'; + +import { decodeBase64, decodeUtf8Lossless, encodeBase64, encodeUtf8, sha256Digest } from '../../../src/ext/skills/bytes'; + +describe('bytes', () => { + it('round-trips base64 for every byte value', () => { + const bytes = Uint8Array.from({ length: 256 }, (_, index) => index); + expect(decodeBase64(encodeBase64(bytes))).toEqual(bytes); + }); + + it('decodes UTF-8 losslessly, keeping a BOM, and rejects invalid UTF-8', () => { + const withBom = Uint8Array.from([0xef, 0xbb, 0xbf, 0x61]); + expect(decodeUtf8Lossless(withBom)).toBe('\uFEFFa'); + expect(encodeUtf8(decodeUtf8Lossless(withBom)!)).toEqual(withBom); + expect(decodeUtf8Lossless(Uint8Array.from([0xff, 0xfe]))).toBeUndefined(); + }); + + it('computes sha256 digests identical to node:crypto', async () => { + const bytes = encodeUtf8('hello skills'); + expect(await sha256Digest(bytes)).toBe(`sha256:${createHash('sha256').update(bytes).digest('hex')}`); + }); + + it('hashes only the bytes of a view, and a view over a SharedArrayBuffer', async () => { + const expected = `sha256:${createHash('sha256').update('hello skills').digest('hex')}`; + expect(await sha256Digest(encodeUtf8('xxhello skillsyy').subarray(2, -2))).toBe(expected); + const shared = new Uint8Array(new SharedArrayBuffer(12)); + shared.set(encodeUtf8('hello skills')); + expect(await sha256Digest(shared)).toBe(expected); + }); + + it('hashes an ArrayBuffer-backed input without copying it', async () => { + const digest = vi.spyOn(globalThis.crypto.subtle, 'digest'); + try { + const bytes = encodeUtf8('hello skills'); + await sha256Digest(bytes); + expect((digest.mock.calls[0]![1] as ArrayBufferView).buffer).toBe(bytes.buffer); + } finally { + digest.mockRestore(); + } + }); +}); diff --git a/packages/core-internal/test/ext/skills/entry.test.ts b/packages/core-internal/test/ext/skills/entry.test.ts new file mode 100644 index 0000000000..601904b909 --- /dev/null +++ b/packages/core-internal/test/ext/skills/entry.test.ts @@ -0,0 +1,160 @@ +import { createHash } from 'node:crypto'; + +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { describe, expect, it } from 'vitest'; + +import { encodeUtf8 } from '../../../src/ext/skills/bytes'; +import { buildSkillEntry, skillEntryProblems, validateSkillEntry } from '../../../src/ext/skills/entry'; + +const digest = `sha256:${'a'.repeat(64)}`; +const entry = (overrides: Partial = {}): Skill => ({ + uri: 'skill://acme/refunds/SKILL.md', + frontmatter: { name: 'refunds', description: 'Refund workflow.' }, + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/references/policy.md', digest, size: 1 } + ], + ...overrides +}); + +describe('validateSkillEntry', () => { + it('accepts a valid entry and a dynamic one', () => { + expect(validateSkillEntry(entry())).toEqual([]); + expect(validateSkillEntry(entry({ resources: 'dynamic' }))).toEqual([]); + }); + + it.each([ + ['a URI not ending in /SKILL.md', entry({ uri: 'skill://acme/refunds/README.md' }), /must be a URI ending in \/SKILL\.md/], + [ + 'an empty segment before SKILL.md', + entry({ uri: 'skill://acme//SKILL.md', frontmatter: { name: 'acme', description: 'd' }, resources: 'dynamic' }), + /must be a URI ending in \/SKILL\.md/ + ], + [ + 'a name that does not match its folder', + entry({ frontmatter: { name: 'refund', description: 'd' } }), + /must match the last path segment/ + ], + ['an invalid name', entry({ frontmatter: { name: 'Refunds', description: 'd' } }), /lowercase/], + ['an empty description', entry({ frontmatter: { name: 'refunds', description: '' } }), /description/], + ['a non-string metadata value', entry({ frontmatter: { name: 'refunds', description: 'd', metadata: { v: 1 } } }), /metadata/], + ['a missing SKILL.md entry', entry({ resources: [{ uri: 'skill://acme/refunds/x.md', digest, size: 1 }] }), /must include/], + [ + 'a file outside the skill', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/other/x.md', digest, size: 1 } + ] + }), + /outside the skill/ + ], + [ + 'an escaping file', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/../other/x.md', digest, size: 1 } + ] + }), + /outside the skill/ + ], + [ + 'a file whose segment decodes to a traversal', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/x%2F..%2F..%2Fother%2Fx.md', digest, size: 1 } + ] + }), + /outside the skill/ + ], + [ + 'a duplicate file', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 } + ] + }), + /more than once/ + ], + [ + 'a file listed again under another percent-encoding', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/Q&A.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/Q%26A.md', digest, size: 1 } + ] + }), + /"skill:\/\/acme\/refunds\/Q%26A\.md" is listed more than once/ + ] + ])('reports %s', (_label, skill, problem) => { + expect(validateSkillEntry(skill).join('\n')).toMatch(problem); + }); + + it.each(['policy.md#part', 'policy.md#', 'policy.md?v=1', 'policy.md?'])('reports a file URI with a query or fragment (%s)', suffix => { + const skill = entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: `skill://acme/refunds/references/${suffix}`, digest, size: 1 } + ] + }); + expect(validateSkillEntry(skill)).toEqual([ + `resource "skill://acme/refunds/references/${suffix}" must not have a query or fragment` + ]); + }); + + it('counts the length limits in characters, not UTF-16 code units', () => { + const problems = (fields: Record) => + validateSkillEntry(entry({ frontmatter: { name: 'refunds', description: 'd', ...fields } })).join('\n'); + expect(problems({ description: '😀'.repeat(1024), compatibility: '😀'.repeat(500) })).toBe(''); + expect(problems({ description: '😀'.repeat(1025) })).toMatch(/description/); + expect(problems({ compatibility: '😀'.repeat(501) })).toMatch(/compatibility/); + }); + + it('skillEntryProblems also catches schema violations', () => { + expect(skillEntryProblems({ uri: 'skill://a/SKILL.md' }).length).toBeGreaterThan(0); + expect(skillEntryProblems(entry())).toEqual([]); + }); +}); + +describe('buildSkillEntry', () => { + it('builds a valid entry with digests computed from the bytes, sorted by URI', async () => { + const manifest = encodeUtf8('\uFEFF---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\nBody\r\n'); + const policy = encodeUtf8('# Policy\n'); + // Reverse code-unit order on purpose, so the output order proves the sort. + const { skill, problems } = await buildSkillEntry('skill://acme/refunds/SKILL.md', [ + { uri: 'skill://acme/refunds/references/policy.md', bytes: policy }, + { uri: 'skill://acme/refunds/SKILL.md', bytes: manifest } + ]); + expect(problems).toEqual([]); + expect(skill?.frontmatter).toEqual({ name: 'refunds', description: 'Refund workflow.' }); + expect(skill?.resources).toEqual([ + { + uri: 'skill://acme/refunds/SKILL.md', + digest: `sha256:${createHash('sha256').update(manifest).digest('hex')}`, + size: manifest.byteLength + }, + { + uri: 'skill://acme/refunds/references/policy.md', + digest: `sha256:${createHash('sha256').update(policy).digest('hex')}`, + size: policy.byteLength + } + ]); + }); + + it('reports invalid UTF-8, bad frontmatter and missing fields', async () => { + const uri = 'skill://acme/refunds/SKILL.md'; + expect(await buildSkillEntry(uri, [{ uri, bytes: Uint8Array.from([0xff]) }])).toEqual({ + problems: ['SKILL.md is not valid UTF-8'] + }); + expect(await buildSkillEntry(uri, [{ uri, bytes: encodeUtf8('---\nname: &a refunds\n---\n') }])).toMatchObject({ + problems: [expect.stringMatching(/frontmatter/)] + }); + expect(await buildSkillEntry(uri, [{ uri, bytes: encodeUtf8('---\nname: refunds\n---\n') }])).toMatchObject({ + problems: [expect.stringMatching(/"name" and "description"/)] + }); + }); +}); diff --git a/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts new file mode 100644 index 0000000000..5012776464 --- /dev/null +++ b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import YAML from 'yaml'; + +import { FrontmatterError, parseFrontmatterYaml } from '../../../src/ext/skills/frontmatter'; + +/** Real-world-shaped frontmatter the parser must read exactly as a YAML 1.2 library does. */ +const ACCEPTED: readonly string[] = [ + 'name: pdf-processing\ndescription: Extract PDF text, fill forms, merge files. Use when handling PDFs.\n', + 'name: pdf-processing\ndescription: d\nlicense: Apache-2.0\nmetadata:\n author: example-org\n version: "1.0"\n', + 'name: x\ndescription: d\nallowed-tools: Bash(git:*) Bash(jq:*) Read\ndisable-model-invocation: true\nargument-hint: "[file]"\n', + 'name: x\ndescription: >-\n Extracts text and tables from PDF files,\n fills PDF forms.\n\n Use when working with PDFs.\n', + 'name: x\ndescription: |\n Line one\n Line two\n\n Line four\n', + 'name: x\ndescription: >\n folded\n more indented\n back\n', + 'name: x\ndescription: |+\n keep\n\n\nnext: 1\n', + 'name: x\ndescription: first line\n continued\n', + 'name: x\ndescription: d\ntags:\n- a\n- b\nitems:\n - name: one\n weight: 1.5\n - name: two\n', + 'name: x\ndescription: d\nflow: [a, "b c", 1, true, null, {k: v}]\nmap: {a: 1, b: [x, y]}\n', + "name: x\ndescription: 'it''s quoted'\nescaped: \"tab\\tnewline\\nunicode \\u00e9 \\x41\"\n", + 'name: x # trailing comment\n# full-line comment\ndescription: d\nlang: c#\nurl: https://example.test/a#b\n', + 'name: x\ndescription: d\nints: [0, -1, +2, 0x1f, 0o17]\nfloats: [1.0, .5, 1e3, -2.5E-2]\nnulls: [~, null, Null]\nbools: [true, False, TRUE]\nstrings: [yes, no, on, off, 1.2.3]\n', + 'name: x\ndescription: d\nempty:\nquoted-empty: ""\nunicode: "日本語 ✓"\n', + 'name: x\ndescription: d\nnested:\n level1:\n level2:\n - deep\n', + 'name: x\ndescription: d\ntags:\n-\ta\n-\tb\n', + 'name: x\ndescription: d\nflow: [-a, ?b, :c]\nmap: {-k: v, ?k: w}\n', + 'name: x\ndescription: d\nflow: [http://x.test/a, b:c, a#b]\nmap: {a:b: c, :k: v, http://x.test: 1, u: http://x.test, k#: v#}\n', + // A tab after a block scalar's content indentation is content. + 'name: x\ndescription: |\n foo\n \tbar\n', + 'name: x\ndescription: |\n \tfoo\n bar\n', + 'name: x\ndescription: |\n \tfoo\n', + 'name: x\ndescription: |\n \t\n foo\n', + 'name: x\ndescription: |-\n foo\n \t\n', + 'name: x\ndescription: |\n foo\n \t# not a comment\n', + 'name: x\ndescription: >\n a\n b\n \tc\n d\n e\n', + 'name: x\ndescription: >\n a\n b\n \tc\n d\n', + 'name: x\ndescription: >\n foo\n\n \tbar\n\n baz\n', + 'name: x\nmetadata:\n note: |\n foo\n \tbar\n next: "1"\n', + 'name: x\ntags:\n- |\n foo\n \tbar\n- x\n' +]; + +/** Valid YAML the parser deliberately rejects (it must fail closed, never misread). */ +const REJECTED: readonly string[] = [ + 'base: &b 1\nref: *b\n', + 'value: !!str 1\n', + 'list: [1,\n 2]\n', + 'quoted: "line\n continued"\n', + 'block: |2\n indented\n', + 'flow: [? x]\n', + 'flow: [:]\n', + 'map: {? k: v}\n', + 'map: {!t k: v}\n', + 'map: {&a k: v}\n', + 'flow: [x:]\n', + 'flow: [x:, y]\n', + 'flow: [x, y:]\n', + 'flow: [x::]\n', + 'flow: [x:y:]\n', + 'map: {k: [x:]}\n', + 'map: {[x]: y}\n', + 'map: {k: v, [x]: y}\n', + 'map: {{x: 1}: y}\n', + // yaml drops a byte order mark before the first content; the parser does not guess. + '\uFEFFname: x\n', + '# comment\n\uFEFFname: x\n', + // yaml reads `__proto__` as an own key; the parser rejects it wherever a key can be written. + '"__proto__": x\n', + "m:\n '__proto__': x\n", + 'm: {__proto__: x}\n', + 'm: {"__proto__": x}\n' +]; + +/** Invalid YAML: the yaml library rejects it, so the parser must too. */ +const INVALID: readonly string[] = [ + 'a: ,x\n', + 'a: ]x\n', + 'a: ?\tx\n', + ']k: v\n', + 'a: -\tb\n', + 'k:\n-\tk: v\n', + 'a: [- x]\n', + 'a: [-]\n', + 'a: [|x]\n', + 'a: [#x]\n', + 'a: {- k: v}\n', + 'a: {|k: v}\n', + 'a: x #c\n y\n', + 'a: |\n \n x\n', + 'a: "\\U00110000"\n', + 'a: [x{y]\n', + 'a: [x}y]\n', + 'a: {k: x]y}\n', + 'a: {k: x[y}\n', + 'a: {k: x{y}\n', + 'a: {k: v:}\n', + 'a: {k: v:, j: w}\n', + 'a: {x #y: z}\n', + 'a: {x]: y}\n', + 'a: {x[: y}\n', + // A tab inside a block scalar's indentation, blank and comment lines included. + 'a: |\n\tfoo\n', + 'a: |\n\t\n foo\n', + 'a: |\n foo\n \tbar\n', + 'a: |\n foo\n\t\n bar\n', + 'a: |\n foo\n \t\n', + 'a: |\n foo\n\t\nb: 1\n', + 'a: |\n foo\n\t# c\nb: 1\n', + 'k:\n a: |\n foo\n \tbar\n' +]; + +/** + * Characters that JavaScript's `trim` strips (NEL aside, which it keeps) but that YAML 1.2 reads as content: its + * only white space is space and tab (`s-white`). + */ +const NON_YAML_SPACES: Readonly> = { + 'U+00A0': '\u00A0', + 'U+2003': '\u2003', + 'U+3000': '\u3000', + 'U+2028': '\u2028', + 'U+2029': '\u2029', + 'U+FEFF': '\uFEFF', + 'U+000B': '\u000B', + 'U+000C': '\u000C', + 'U+0085': '\u0085' +}; + +/** Every place the parser skips white space, with `c` where a trim would wrongly drop it. */ +const SPACE_POSITIONS: ReadonlyArray<(c: string) => string> = [ + c => `name: x\ndescription: a${c}\n`, + c => `name: x\ndescription: ${c}a\n`, + c => `name: x\ndescription: ${c}\n`, + c => `name${c}: x\n`, + c => `description: a${c}# not a comment\n`, + c => `description: a${c}b\n`, + c => `description: a${c}\n continued\n`, + c => `description: a\n ${c}\n`, + c => `description: |\n a${c}\n`, + c => `tags: [a${c}, ${c}b]\n`, + c => `map: {k${c}: v${c}}\n`, + c => `tags:\n- ${c}a\nitems:\n- ${c}k: v\n` +]; + +const OPTIONS = { version: '1.2', schema: 'core', uniqueKeys: true, merge: false } as const; + +describe('frontmatter parser agrees with the yaml library (YAML 1.2 core schema)', () => { + it.each(ACCEPTED)('reads %j exactly like yaml', source => { + const expected = YAML.parse(source, OPTIONS) as unknown; + expect(parseFrontmatterYaml(source)).toEqual(expected); + }); + + it.each(REJECTED)('fails closed on %j', source => { + expect(() => parseFrontmatterYaml(source)).toThrow(FrontmatterError); + }); + + it.each(INVALID)('rejects %j like yaml', source => { + expect(() => YAML.parse(source, OPTIONS)).toThrow(); + expect(() => parseFrontmatterYaml(source)).toThrow(FrontmatterError); + }); +}); + +describe('Unicode white space other than space and tab is content, as in yaml', () => { + const cases = Object.entries(NON_YAML_SPACES).flatMap(([name, c]) => SPACE_POSITIONS.map(position => [name, position(c)] as const)); + + it.each(cases)('%s: reads %j exactly like yaml', (_name, source) => { + const expected = YAML.parse(source, OPTIONS) as unknown; + expect(parseFrontmatterYaml(source)).toEqual(expected); + }); + + it.each(Object.entries(NON_YAML_SPACES))('%s after a quoted value is rejected, like yaml', (_name, c) => { + const source = `description: "a"${c}\n`; + expect(() => YAML.parse(source, OPTIONS)).toThrow(); + expect(() => parseFrontmatterYaml(source)).toThrow(FrontmatterError); + }); +}); diff --git a/packages/core-internal/test/ext/skills/frontmatter.test.ts b/packages/core-internal/test/ext/skills/frontmatter.test.ts new file mode 100644 index 0000000000..73d5e7563a --- /dev/null +++ b/packages/core-internal/test/ext/skills/frontmatter.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import { FrontmatterError, parseFrontmatterYaml, parseSkillMarkdown, splitFrontmatter } from '../../../src/ext/skills/frontmatter'; + +describe('splitFrontmatter', () => { + it('splits frontmatter and body', () => { + expect(splitFrontmatter('---\nname: a\n---\n# Body\n')).toEqual({ yaml: 'name: a\n', body: '# Body\n' }); + }); + + it('accepts CRLF line endings and a leading UTF-8 BOM', () => { + expect(splitFrontmatter('\uFEFF---\r\nname: a\r\n---\r\nBody\r\n')).toEqual({ yaml: 'name: a\n', body: 'Body\n' }); + }); + + it('rejects a file without frontmatter or without a closing line', () => { + expect(() => splitFrontmatter('# no frontmatter')).toThrow(FrontmatterError); + expect(() => splitFrontmatter('---\nname: a\n')).toThrow(/closing/); + }); +}); + +describe('parseFrontmatterYaml: scalars', () => { + it('types unquoted values with the YAML 1.2 core schema', () => { + expect( + parseFrontmatterYaml( + ['a: true', 'b: False', 'c: ~', 'd:', 'e: 42', 'f: -1.5', 'g: 1e3', 'h: 0x1F', 'i: 0o17', 'j: .5', 'k: yes', 'l: 1.0'].join( + '\n' + ) + ) + ).toEqual({ a: true, b: false, c: null, d: null, e: 42, f: -1.5, g: 1000, h: 31, i: 15, j: 0.5, k: 'yes', l: 1 }); + }); + + it('keeps quoted values as strings and decodes escapes', () => { + expect( + parseFrontmatterYaml(String.raw`a: "1.0"` + '\n' + String.raw`b: 'it''s'` + '\n' + String.raw`c: "tab\there \u00e9"`) + ).toEqual({ + a: '1.0', + b: "it's", + c: 'tab\there é' + }); + }); + + it('strips comments only when preceded by whitespace', () => { + expect(parseFrontmatterYaml('lang: c#\nnote: hello # a comment\nurl: http://x.test/a#b')).toEqual({ + lang: 'c#', + note: 'hello', + url: 'http://x.test/a#b' + }); + }); + + it('folds multi-line plain values', () => { + expect(parseFrontmatterYaml('description: first line\n second line\n\n after blank')).toEqual({ + description: 'first line second line\nafter blank' + }); + }); +}); + +describe('parseFrontmatterYaml: block scalars', () => { + it('reads literal and folded scalars with chomping', () => { + const yaml = [ + 'lit: |', + ' a', + ' b', + '', + 'strip: |-', + ' x', + 'keep: |+', + ' y', + '', + 'fold: >', + ' one', + ' two', + '', + ' three', + 'next: 1' + ].join('\n'); + expect(parseFrontmatterYaml(yaml)).toEqual({ lit: 'a\nb\n', strip: 'x', keep: 'y\n\n', fold: 'one two\nthree\n', next: 1 }); + }); +}); + +describe('parseFrontmatterYaml: collections', () => { + it('reads nested mappings, sequences, compact sequences and flow collections', () => { + const yaml = [ + 'metadata:', + ' author: example-org', + ' version: "1.0"', + 'tags:', + '- a', + '- b', + 'items:', + ' - name: x', + ' value: 1', + ' - - nested', + 'flow: [a, "b", {k: v}, ]', + 'map: {x: 1, y: [2, 3]}' + ].join('\n'); + expect(parseFrontmatterYaml(yaml)).toEqual({ + metadata: { author: 'example-org', version: '1.0' }, + tags: ['a', 'b'], + items: [{ name: 'x', value: 1 }, ['nested']], + flow: ['a', 'b', { k: 'v' }], + map: { x: 1, y: [2, 3] } + }); + }); +}); + +describe('parseFrontmatterYaml: fails closed', () => { + it.each([ + ['an anchor', 'a: &x 1'], + ['an alias', 'a: *x'], + ['a tag', 'a: !!str 1'], + ['a complex key', '? a\n: b'], + ['a multi-line flow collection', 'a: [1,\n 2]'], + ['a multi-line quoted value', 'a: "x\n y"'], + ['a duplicate key', 'a: 1\na: 2'], + ['tab indentation', 'a:\n\tb: 1'], + ['": " inside a plain value', 'a: b: c'], + ['a non-finite number', 'a: .inf'], + ['an indentation indicator', 'a: |2\n x'], + ['a top-level sequence', '- a'], + ['a non-string key', '1: a'], + ['the __proto__ key', '__proto__: x'], + ['an unknown escape', String.raw`a: "\q"`], + ['a directive', '%YAML 1.2\na: 1'], + ['an unclosed quote', 'a: "x'] + ])('rejects %s', (_label, yaml) => { + expect(() => parseFrontmatterYaml(yaml)).toThrow(FrontmatterError); + }); + + it('reports the 1-based line number within SKILL.md', () => { + expect(() => parseSkillMarkdown('---\nname: a\nbad: &x 1\n---\n')).toThrow(/line 3/); + }); + + it('rejects nesting deeper than 32 levels', () => { + expect(() => parseFrontmatterYaml(`a: ${'['.repeat(40)}${']'.repeat(40)}`)).toThrow(/deeper than 32/); + }); +}); + +describe('parseSkillMarkdown', () => { + it('returns frontmatter and body', () => { + expect(parseSkillMarkdown('---\nname: git-workflow\ndescription: d\n---\n# Git\n')).toEqual({ + frontmatter: { name: 'git-workflow', description: 'd' }, + body: '# Git\n' + }); + }); +}); diff --git a/packages/core-internal/test/ext/skills/mime.test.ts b/packages/core-internal/test/ext/skills/mime.test.ts new file mode 100644 index 0000000000..c10bd12e91 --- /dev/null +++ b/packages/core-internal/test/ext/skills/mime.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { mimeTypeFor } from '../../../src/ext/skills/mime'; + +describe('mimeTypeFor', () => { + it('maps known extensions, case-insensitively', () => { + expect(mimeTypeFor('SKILL.md')).toBe('text/markdown'); + expect(mimeTypeFor('chart.PNG')).toBe('image/png'); + }); + + it.each(['notes', '.env', 'archive.unknown', 'notes.__proto__', 'x.constructor', 'x.hasOwnProperty', 'x.valueOf'])( + 'falls back to application/octet-stream for %s', + name => { + expect(mimeTypeFor(name)).toBe('application/octet-stream'); + } + ); +}); diff --git a/packages/core-internal/test/ext/skills/uri.test.ts b/packages/core-internal/test/ext/skills/uri.test.ts new file mode 100644 index 0000000000..79e18bb96a --- /dev/null +++ b/packages/core-internal/test/ext/skills/uri.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalUri, + compareUris, + encodeSegment, + isRoundTripUri, + isWithin, + joinUri, + lastSegmentOf, + resolveSkillRef, + skillFileKey, + skillRootUri, + trimTrailingSlashes +} from '../../../src/ext/skills/uri'; + +describe('uri helpers', () => { + it('joins and percent-encodes segments so they survive new URL()', () => { + const uri = joinUri('skill://', ['acme', 'refunds', 'templates', 'Purchase Order.md']); + expect(uri).toBe('skill://acme/refunds/templates/Purchase%20Order.md'); + expect(isRoundTripUri(uri)).toBe(true); + expect(joinUri('github://owner/repo/skills/', ['x', 'SKILL.md'])).toBe('github://owner/repo/skills/x/SKILL.md'); + expect(joinUri('github://owner/repo/skills///', ['x'])).toBe('github://owner/repo/skills/x'); + }); + + it('trims only trailing slashes, in linear time on a long run of them', () => { + expect(trimTrailingSlashes('a/b//')).toBe('a/b'); + expect(trimTrailingSlashes('a/b')).toBe('a/b'); + expect(trimTrailingSlashes('///')).toBe(''); + expect(trimTrailingSlashes('')).toBe(''); + // CodeQL js/polynomial-redos: `/\/+$/` takes ~4 s on this input; the loop takes well under 1 ms. + const slashes = `x${'/'.repeat(100_000)}y`; + const startTime = Date.now(); + expect(trimTrailingSlashes(slashes)).toBe(slashes); + expect(Date.now() - startTime).toBeLessThan(100); + }); + + it.each(['Q&A.md', 'c++.md', '[draft].md', 'me@home.md', 'a,b;c=d$e|f!(g)*~h.md', 'Purchase Order.md', 'Remboursé.md', 'x{y}z.md'])( + 'publishes %s exactly as new URL() resolves the plain name against the root', + name => { + const published = joinUri('skill://acme/refunds', [name]); + expect(published).toBe(new URL(name, 'skill://acme/refunds/').href); + expect(isRoundTripUri(published)).toBe(true); + } + ); + + it('encodes ^ on every runtime, and keys a host that leaves it raw alike', () => { + const root = 'skill://acme/refunds'; + const published = joinUri(root, ['x^y.md']); + expect(published).toBe('skill://acme/refunds/x%5Ey.md'); + // Node 24 resolves `x^y.md` to `x%5Ey.md`; Node 20 and 22 leave the `^` raw. + for (const uri of [published, 'skill://acme/refunds/x^y.md', new URL('x^y.md', `${root}/`).href]) { + expect(skillFileKey(root, uri)).toBe(published); + } + }); + + it.each([ + ['me@home', 'me%40home'], + ['acme:prod', 'acme%3Aprod'], + ['[draft]', '%5Bdraft%5D'], + ['a|b', 'a%7Cb'] + ])('encodes a first segment %s strictly, since it is the authority of a skill:// URI', (first, authority) => { + const uri = joinUri('skill://', [first, 'refunds', 'SKILL.md']); + expect(uri).toBe(`skill://${authority}/refunds/SKILL.md`); + expect(isRoundTripUri(uri)).toBe(true); + expect(new URL(uri).host).toBe(authority); + expect(resolveSkillRef(uri, 'references/Q&A.md')).toBe(`skill://${authority}/refunds/references/Q&A.md`); + // Below the authority, path segments follow the path rules. + expect(joinUri(`skill://${authority}`, [first])).toBe(`skill://${authority}/${first}`); + }); + + it('always encodes the characters that would change what a segment means', () => { + expect(encodeSegment('100%.md')).toBe('100%25.md'); + expect(encodeSegment(String.raw`a/b?c#d\e`)).toBe('a%2Fb%3Fc%23d%5Ce'); + expect(encodeSegment('a:b.md')).toBe('a:b.md'); + expect(encodeSegment('Remboursé.md')).toBe('Rembours%C3%A9.md'); + }); + + it('encodes the dots of a "." or ".." segment, which WHATWG URL still reads as a dot segment', () => { + expect(encodeSegment('.')).toBe('%2E'); + expect(encodeSegment('..')).toBe('%2E%2E'); + expect(encodeSegment('...')).toBe('...'); + expect(isRoundTripUri(joinUri('skill://acme/refunds', ['..', 'x.md']))).toBe(false); + expect(isWithin('skill://acme/refunds', joinUri('skill://acme/refunds', ['..', 'x.md']))).toBe(false); + }); + + it('throws URIError for a lone surrogate', () => { + expect(() => encodeSegment('x\uD800.md')).toThrow(URIError); + expect(() => joinUri('skill://acme', ['empty\uDC00'])).toThrow(URIError); + expect(() => joinUri('skill://', ['x\uD800', 'SKILL.md'])).toThrow(URIError); + }); + + it('keys every spelling of one file below the root alike', () => { + const root = 'skill://acme/refunds'; + for (const uri of ['skill://acme/refunds/Q&A.md', 'skill://acme/refunds/Q%26A.md', 'skill://acme/refunds/%51&A.md#part']) { + expect(skillFileKey(root, uri)).toBe('skill://acme/refunds/Q&A.md'); + } + expect(skillFileKey(root, 'skill://acme/refunds/r%c3%a9sum%C3%A9.md')).toBe('skill://acme/refunds/r%C3%A9sum%C3%A9.md'); + expect(skillFileKey(root, 'skill://acme/refunds/100%.md')).toBe('skill://acme/refunds/100%25.md'); + expect(skillFileKey(root, 'skill://acme/refunds/100%25.md')).toBe('skill://acme/refunds/100%25.md'); + expect(skillFileKey(root, 'skill://acme/refunds/x%2Fy.md')).toBe('skill://acme/refunds/x%2Fy.md'); + expect(skillFileKey(root, 'skill://acme/refunds/x.md?v=1')).toBe('skill://acme/refunds/x.md?v=1'); + expect(skillFileKey(root, 'skill://acme/refunds/x.md?')).toBe('skill://acme/refunds/x.md?'); + expect(skillFileKey(root, 'skill://acme/other/x.md#f')).toBe('skill://acme/other/x.md'); + expect(skillFileKey(root, 'not a uri')).toBeUndefined(); + }); + + it.each([ + '100% off.md', + '50% résumé.md', + 'x%zz é.md', + 'a % b.md', + '10%Dec.md', + '50%Fee.md', + '100%Beef.md', + '2%Ad.md', + '50%ff.md', + '100%Cake.md', + '20%E2 x.md', + '%Café.md' + ])('keys %s, a name with a stray %, as published whatever follows it', name => { + const root = 'skill://acme/refunds'; + expect(skillFileKey(root, new URL(name, `${root}/`).href)).toBe(joinUri(root, [name])); + expect(skillFileKey(root, joinUri(root, [name]))).toBe(joinUri(root, [name])); + }); + + it.each(['10%Dec.md', '%FF', 'a%2Fb', 'é%', '😀%41', '%41.md'])('keys the published URI of %s as itself', name => { + const root = 'skill://acme/refunds'; + const published = joinUri(root, [name]); + expect(skillFileKey(root, published)).toBe(published); + }); + + it('keys an escape that does not start valid UTF-8 as a literal %, as encodeSegment publishes it', () => { + const root = 'skill://acme/refunds'; + const key = (segment: string): string | undefined => skillFileKey(root, `${root}/${segment}`); + expect(key('%ff%41.md')).toBe(`${root}/%25ffA.md`); + expect(key('%c3%a9%C3.md')).toBe(`${root}/%C3%A9%25C3.md`); + // Overlong, surrogate, out-of-range and truncated sequences are not UTF-8: each of their `%` is literal. + expect(key('%C0%80')).toBe(`${root}/%25C0%2580`); + expect(key('%ED%A0%80')).toBe(`${root}/%25ED%25A0%2580`); + expect(key('%F4%90%80%80')).toBe(`${root}/%25F4%2590%2580%2580`); + expect(key('%e2%82')).toBe(`${root}/%25e2%2582`); + expect(key('%f0%9f%98%80')).toBe(`${root}/%F0%9F%98%80`); + // Decoding never carries a slash out of its segment. + expect(key('%2E%2E%2F')).toBe(`${root}/..%2F`); + expect(isWithin(root, `${root}/..%2F`)).toBe(false); + }); + + it('finds the skill root and its last segment', () => { + expect(skillRootUri('skill://acme/billing/refunds/SKILL.md')).toBe('skill://acme/billing/refunds'); + expect(skillRootUri('skill://git-workflow/SKILL.md')).toBe('skill://git-workflow'); + expect(skillRootUri('skill://git-workflow/README.md')).toBeUndefined(); + expect(skillRootUri('skill://acme//SKILL.md')).toBeUndefined(); + expect(skillRootUri('skill://acme/refunds//SKILL.md')).toBeUndefined(); + expect(skillRootUri('skill://SKILL.md')).toBeUndefined(); + expect(lastSegmentOf('skill://acme/billing/refunds')).toBe('refunds'); + expect(lastSegmentOf('skill://git-workflow')).toBe('git-workflow'); + }); + + it('checks containment after normalisation', () => { + expect(isWithin('skill://a', 'skill://a/x/y.md')).toBe(true); + expect(isWithin('skill://a', 'skill://ab/x.md')).toBe(false); + expect(isWithin('skill://a/b', 'skill://a/b/../c')).toBe(false); + expect(isWithin('skill://a/b', 'skill://a/b/%2e%2e/c')).toBe(false); + }); + + it('rejects segments that decode to a slash or NUL, but keeps backslashes', () => { + expect(isWithin('skill://a/b', 'skill://a/b/x%2F..%2F..%2Fetc%2Fpasswd')).toBe(false); + expect(isWithin('skill://a/b', 'skill://a/b/x%2fy.md')).toBe(false); + expect(isWithin('skill://a/b', 'skill://a/b/x%00y.md')).toBe(false); + const backslash = joinUri('skill://a/b', ['dir\\name.md']); + expect(backslash).toBe('skill://a/b/dir%5Cname.md'); + expect(isWithin('skill://a/b', backslash)).toBe(true); + }); + + it('resolves references against the skill root as the spec examples do', () => { + const skill = 'skill://acme/billing/refunds/SKILL.md'; + expect(resolveSkillRef(skill)).toBe(skill); + expect(resolveSkillRef(skill, 'references/GUIDE.md')).toBe('skill://acme/billing/refunds/references/GUIDE.md'); + expect(resolveSkillRef(skill, './templates/Purchase Order.md')).toBe('skill://acme/billing/refunds/templates/Purchase%20Order.md'); + expect(resolveSkillRef(skill, '../other/SKILL.md')).toBe('skill://acme/billing/other/SKILL.md'); + expect(resolveSkillRef(skill, 'skill://acme/billing/refunds/scripts/run.sh')).toBe('skill://acme/billing/refunds/scripts/run.sh'); + expect(() => resolveSkillRef('skill://acme/README.md', 'x')).toThrow(TypeError); + }); + + it('canonicalUri returns undefined for non-URIs', () => { + expect(canonicalUri('not a uri')).toBeUndefined(); + }); + + it('orders URIs by code unit, whatever the locale', () => { + expect(['skill://b', 'skill://a/SKILL.md', 'skill://B', 'skill://a/b'].toSorted((a, b) => compareUris(a, b))).toEqual([ + 'skill://B', + 'skill://a/SKILL.md', + 'skill://a/b', + 'skill://b' + ]); + }); +}); diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 979ff15070..5bc882e8e3 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -37,11 +37,15 @@ function readManifest(relativeDir: string): PackageManifest { const PUBLIC_PACKAGES: Record }> = { client: { name: '@modelcontextprotocol/client', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + // './ext/skills' is the client side of the Skills extension (SEP-2640) — public API, + // kept off the root barrel so the extension surface stays opt-in and separately versioned. + exportKeys: ['.', './stdio', './ext/skills', './validators/ajv', './validators/cf-worker', './_shims'] }, server: { name: '@modelcontextprotocol/server', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + // './ext/skills' — the server side of the same extension; './ext/skills/node' + // — its Node-only folder adapter (kept off './ext/skills' so that stays runtime-neutral). + exportKeys: ['.', './stdio', './ext/skills', './ext/skills/node', './validators/ajv', './validators/cf-worker', './_shims'] }, 'server-legacy': { name: '@modelcontextprotocol/server-legacy', @@ -56,7 +60,10 @@ const PUBLIC_PACKAGES: Record; +} + +/** + * Reads the Skills extension capability out of a peer's advertised + * capabilities, returning `undefined` when the peer does not advertise the + * extension or advertises a malformed value. + * + * Shared by both roles: servers use it to check what they published, clients + * to gate `skills/list` / `skills/get` on negotiation. + */ +export function skillsCapabilityOf(capabilities: CapabilitiesWithExtensions | undefined): SkillsCapability | undefined { + const declared = capabilities?.extensions?.[SKILLS_EXTENSION_ID]; + if (declared === undefined) { + return undefined; + } + const parsed = SkillsCapabilitySchema.safeParse(declared); + return parsed.success ? parsed.data : undefined; +} diff --git a/packages/core/src/ext/skills/constants.ts b/packages/core/src/ext/skills/constants.ts new file mode 100644 index 0000000000..9ee95270fc --- /dev/null +++ b/packages/core/src/ext/skills/constants.ts @@ -0,0 +1,29 @@ +/** + * Wire constants for the MCP Skills extension (SEP-2640). + * + * Runtime-neutral: this module imports nothing. + */ + +/** The identifier a server advertises in `ServerCapabilities.extensions`. */ +export const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; + +/** Enumerates the skills a server serves. */ +export const SKILLS_LIST_METHOD = 'skills/list'; + +/** Returns one skill's entry by its `SKILL.md` URI, listed or not. */ +export const SKILLS_GET_METHOD = 'skills/get'; + +/** Lists the direct children of a directory resource (gated by `directoryRead`). */ +export const RESOURCES_DIRECTORY_READ_METHOD = 'resources/directory/read'; + +/** The `mimeType` of a directory resource. */ +export const DIRECTORY_MIME_TYPE = 'inode/directory'; + +/** + * Per-skill interoperability limits. Hosts MUST accept skills up to these; + * servers SHOULD NOT publish skills over them. + */ +export const SKILL_LIMITS: Readonly<{ files: number; bytes: number }> = Object.freeze({ files: 512, bytes: 16_777_216 }); + +/** `sha256:` followed by 64 lowercase hexadecimal characters. */ +export const SKILL_DIGEST_PATTERN = /^sha256:[\da-f]{64}$/; diff --git a/packages/core/src/ext/skills/index.ts b/packages/core/src/ext/skills/index.ts new file mode 100644 index 0000000000..0544030d45 --- /dev/null +++ b/packages/core/src/ext/skills/index.ts @@ -0,0 +1,49 @@ +// @modelcontextprotocol/core/ext/skills +// +// Shared surface of the MCP Skills extension (SEP-2640): wire constants, Zod +// schemas, the types inferred from them, and the capability reader. Adapted, +// with attribution, from the Apache-2.0 `@olaservo/ext-skills` prototype +// (modelcontextprotocol/ext-skills#71) and typescript-sdk#2818. + +export type { CapabilitiesWithExtensions } from './capability'; +export { skillsCapabilityOf } from './capability'; +export { + DIRECTORY_MIME_TYPE, + RESOURCES_DIRECTORY_READ_METHOD, + SKILL_DIGEST_PATTERN, + SKILL_LIMITS, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD +} from './constants'; +export { + DirectoryResourceSchema, + GetSkillRequestParamsSchema, + GetSkillResultSchema, + ListSkillsRequestParamsSchema, + ListSkillsResultSchema, + ReadResourceDirectoryRequestParamsSchema, + ReadResourceDirectoryResultSchema, + SkillCacheScopeSchema, + SkillDigestSchema, + SkillFrontmatterSchema, + SkillResourceEntrySchema, + SkillResourcesSchema, + SkillsCapabilitySchema, + SkillSchema +} from './schemas'; +export type { + DirectoryResource, + GetSkillRequestParams, + GetSkillResult, + ListSkillsRequestParams, + ListSkillsResult, + ReadResourceDirectoryRequestParams, + ReadResourceDirectoryResult, + Skill, + SkillCacheScope, + SkillFrontmatter, + SkillResourceEntry, + SkillResources, + SkillsCapability +} from './types'; diff --git a/packages/core/src/ext/skills/schemas.ts b/packages/core/src/ext/skills/schemas.ts new file mode 100644 index 0000000000..e581f889e7 --- /dev/null +++ b/packages/core/src/ext/skills/schemas.ts @@ -0,0 +1,84 @@ +import * as z from 'zod/v4'; + +import { BaseRequestParamsSchema, PaginatedRequestParamsSchema, PaginatedResultSchema, ResourceSchema, ResultSchema } from '../../schemas'; +import { SKILL_DIGEST_PATTERN } from './constants'; + +// Every object schema here is loose: none of the stable spec's types declare +// `additionalProperties: false`, so unknown keys (including `_meta`) survive. + +export const SkillDigestSchema = z + .string() + .regex(SKILL_DIGEST_PATTERN, 'digest must be "sha256:" followed by 64 lowercase hexadecimal characters'); + +export const SkillResourceEntrySchema = z.looseObject({ + /** Resource URI of the file. */ + uri: z.string(), + /** SHA-256 digest of the file's raw bytes. */ + digest: SkillDigestSchema, + /** Length of the file's raw bytes. */ + size: z.number().int().nonnegative() +}); + +export const SkillFrontmatterSchema = z + .looseObject({ + name: z.string(), + description: z.string() + }) + // Zod copies an own `__proto__` field by assignment, which makes it the prototype: fields no key listing shows. + .refine(frontmatter => Object.getPrototypeOf(frontmatter) === Object.prototype, 'frontmatter must not have a "__proto__" field'); + +export const SkillResourcesSchema = z.union([z.array(SkillResourceEntrySchema), z.literal('dynamic')]); + +export const SkillSchema = z.looseObject({ + /** Resource URI of the skill's `SKILL.md`. */ + uri: z.string(), + /** The `SKILL.md` frontmatter, verbatim. */ + frontmatter: SkillFrontmatterSchema, + /** Every file of the skill, or `"dynamic"`. */ + resources: SkillResourcesSchema, + _meta: z.record(z.string(), z.unknown()).optional() +}); + +export const SkillCacheScopeSchema = z.enum(['public', 'private']); + +const cacheFields = { + ttlMs: z.number().int().nonnegative().optional(), + cacheScope: SkillCacheScopeSchema.optional() +}; + +export const ListSkillsResultSchema = z.looseObject({ + ...PaginatedResultSchema.shape, + skills: z.array(SkillSchema), + ...cacheFields +}); + +export const GetSkillRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** The `SKILL.md` URI of the skill to fetch. */ + uri: z.string() +}); + +export const GetSkillResultSchema = z.looseObject({ + ...ResultSchema.shape, + skill: SkillSchema, + ...cacheFields +}); + +export const DirectoryResourceSchema = z.looseObject(ResourceSchema.shape); + +export const ReadResourceDirectoryRequestParamsSchema = PaginatedRequestParamsSchema.extend({ + /** URI of the directory resource to read. */ + uri: z.string() +}); + +export const ReadResourceDirectoryResultSchema = z.looseObject({ + ...PaginatedResultSchema.shape, + resources: z.array(DirectoryResourceSchema), + ...cacheFields +}); + +export const SkillsCapabilitySchema = z.looseObject({ + /** Whether the server serves `resources/directory/read`. */ + directoryRead: z.boolean().optional() +}); + +export { PaginatedRequestParamsSchema as ListSkillsRequestParamsSchema } from '../../schemas'; diff --git a/packages/core/src/ext/skills/types.ts b/packages/core/src/ext/skills/types.ts new file mode 100644 index 0000000000..bab2c6e1ee --- /dev/null +++ b/packages/core/src/ext/skills/types.ts @@ -0,0 +1,44 @@ +import type * as z from 'zod/v4'; + +import type { + DirectoryResourceSchema, + GetSkillRequestParamsSchema, + GetSkillResultSchema, + ListSkillsRequestParamsSchema, + ListSkillsResultSchema, + ReadResourceDirectoryRequestParamsSchema, + ReadResourceDirectoryResultSchema, + SkillCacheScopeSchema, + SkillFrontmatterSchema, + SkillResourceEntrySchema, + SkillResourcesSchema, + SkillsCapabilitySchema, + SkillSchema +} from './schemas'; + +/** One file of a skill, with what a host needs to verify it. */ +export type SkillResourceEntry = z.infer; +/** A skill's `SKILL.md` frontmatter as JSON, verbatim. */ +export type SkillFrontmatter = z.infer; +/** A skill's complete file list, or `"dynamic"`. */ +export type SkillResources = z.infer; +/** One skill entry, as `skills/list` and `skills/get` carry it. */ +export type Skill = z.infer; +/** `public` or `private`. */ +export type SkillCacheScope = z.infer; +/** Params of `skills/list`. */ +export type ListSkillsRequestParams = z.infer; +/** Result of `skills/list`. */ +export type ListSkillsResult = z.infer; +/** Params of `skills/get`. */ +export type GetSkillRequestParams = z.infer; +/** Result of `skills/get`. */ +export type GetSkillResult = z.infer; +/** A child of a directory resource. */ +export type DirectoryResource = z.infer; +/** Params of `resources/directory/read`. */ +export type ReadResourceDirectoryRequestParams = z.infer; +/** Result of `resources/directory/read`. */ +export type ReadResourceDirectoryResult = z.infer; +/** The value at `capabilities.extensions["io.modelcontextprotocol/skills"]`. */ +export type SkillsCapability = z.infer; diff --git a/packages/core/test/ext/skills/schemas.test.ts b/packages/core/test/ext/skills/schemas.test.ts new file mode 100644 index 0000000000..dab14124ce --- /dev/null +++ b/packages/core/test/ext/skills/schemas.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { + GetSkillRequestParamsSchema, + GetSkillResultSchema, + ListSkillsResultSchema, + ReadResourceDirectoryRequestParamsSchema, + ReadResourceDirectoryResultSchema, + SKILL_LIMITS, + SKILLS_EXTENSION_ID, + skillsCapabilityOf, + SkillSchema +} from '../../../src/ext/skills/index'; + +const digest = `sha256:${'a'.repeat(64)}`; +const skill = (extra: Record = {}) => ({ + uri: 'skill://git-workflow/SKILL.md', + frontmatter: { name: 'git-workflow', description: 'Branching and review conventions.' }, + resources: [{ uri: 'skill://git-workflow/SKILL.md', digest, size: 10 }], + ...extra +}); + +describe('SkillSchema', () => { + it('keeps _meta and unknown keys', () => { + const parsed = SkillSchema.parse(skill({ _meta: { 'io.modelcontextprotocol.skills/x': 1 }, future: true })); + expect(parsed._meta).toEqual({ 'io.modelcontextprotocol.skills/x': 1 }); + expect((parsed as Record)['future']).toBe(true); + }); + + it('keeps frontmatter fields beyond name and description', () => { + const parsed = SkillSchema.parse(skill({ frontmatter: { name: 'git-workflow', description: 'd', license: 'MIT' } })); + expect(parsed.frontmatter).toEqual({ name: 'git-workflow', description: 'd', license: 'MIT' }); + }); + + it('rejects a frontmatter "__proto__" field, which would otherwise become the prototype', () => { + for (const value of ['{"allowed-tools":"Bash(*)"}', 'null']) { + const frontmatter: unknown = JSON.parse(`{"name":"git-workflow","description":"d","__proto__":${value}}`); + const result = SkillSchema.safeParse(skill({ frontmatter })); + expect(result.success).toBe(false); + expect(result.error?.issues).toEqual([ + expect.objectContaining({ path: ['frontmatter'], message: expect.stringContaining('__proto__') }) + ]); + } + }); + + it('accepts more files than SKILL_LIMITS (limits are policy, not schema)', () => { + const resources = Array.from({ length: SKILL_LIMITS.files + 1 }, (_, index) => ({ + uri: `skill://git-workflow/f${index}`, + digest, + size: 1 + })); + expect(SkillSchema.safeParse(skill({ resources })).success).toBe(true); + }); + + it('accepts "dynamic" and rejects any other string', () => { + expect(SkillSchema.safeParse(skill({ resources: 'dynamic' })).success).toBe(true); + expect(SkillSchema.safeParse(skill({ resources: 'static' })).success).toBe(false); + }); + + it('rejects malformed digests and negative sizes', () => { + expect( + SkillSchema.safeParse(skill({ resources: [{ uri: 'skill://git-workflow/SKILL.md', digest: 'sha256:ABC', size: 1 }] })).success + ).toBe(false); + expect(SkillSchema.safeParse(skill({ resources: [{ uri: 'skill://git-workflow/SKILL.md', digest, size: -1 }] })).success).toBe( + false + ); + }); +}); + +describe('result schemas', () => { + it('ListSkillsResult keeps paging and caching fields and needs no resultType', () => { + const parsed = ListSkillsResultSchema.parse({ skills: [skill()], nextCursor: 'c', ttlMs: 5, cacheScope: 'public' }); + expect(parsed).toMatchObject({ nextCursor: 'c', ttlMs: 5, cacheScope: 'public' }); + }); + + it('GetSkillResult carries one entry', () => { + expect(GetSkillResultSchema.parse({ skill: skill(), ttlMs: 0, cacheScope: 'private' }).skill.uri).toBe( + 'skill://git-workflow/SKILL.md' + ); + }); + + it('ReadResourceDirectoryResult keeps unknown keys on children and caching fields', () => { + const parsed = ReadResourceDirectoryResultSchema.parse({ + resources: [{ uri: 'skill://git-workflow/references', name: 'references', mimeType: 'inode/directory', extra: 1 }], + ttlMs: 0, + cacheScope: 'private' + }); + expect((parsed.resources[0] as Record)['extra']).toBe(1); + expect(parsed.ttlMs).toBe(0); + }); +}); + +describe('request params', () => { + it('skills/get requires a uri', () => { + expect(GetSkillRequestParamsSchema.safeParse({}).success).toBe(false); + expect(GetSkillRequestParamsSchema.parse({ uri: 'skill://a/SKILL.md' }).uri).toBe('skill://a/SKILL.md'); + }); + + it('resources/directory/read takes a uri and an optional cursor', () => { + expect(ReadResourceDirectoryRequestParamsSchema.parse({ uri: 'skill://a', cursor: 'x' })).toMatchObject({ + uri: 'skill://a', + cursor: 'x' + }); + }); +}); + +describe('constants and capability', () => { + it('SKILL_LIMITS matches the SEP', () => { + expect(SKILL_LIMITS).toEqual({ files: 512, bytes: 16_777_216 }); + }); + + it('skillsCapabilityOf reads the extension entry', () => { + expect(skillsCapabilityOf({ extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: true } } })).toEqual({ directoryRead: true }); + expect(skillsCapabilityOf({ extensions: {} })).toBeUndefined(); + expect(skillsCapabilityOf(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index e947feab67..dec96674ef 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'; // makes a node-only dependency leaking in fail the build here instead of silently shipping. export default defineConfig({ failOnWarn: 'ci-only', - entry: ['src/index.ts', 'src/internal.ts'], + entry: ['src/index.ts', 'src/internal.ts', 'src/ext/skills/index.ts'], format: ['esm', 'cjs'], fixedExtension: true, outDir: 'dist', diff --git a/packages/middleware/express/tsconfig.json b/packages/middleware/express/tsconfig.json index 5cf75f3ba6..aa149835df 100644 --- a/packages/middleware/express/tsconfig.json +++ b/packages/middleware/express/tsconfig.json @@ -13,8 +13,14 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ] } } diff --git a/packages/middleware/fastify/tsconfig.json b/packages/middleware/fastify/tsconfig.json index 7e06eb27be..de34390e99 100644 --- a/packages/middleware/fastify/tsconfig.json +++ b/packages/middleware/fastify/tsconfig.json @@ -12,6 +12,9 @@ ], "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" + ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ] } } diff --git a/packages/middleware/hono/tsconfig.json b/packages/middleware/hono/tsconfig.json index 5cf75f3ba6..aa149835df 100644 --- a/packages/middleware/hono/tsconfig.json +++ b/packages/middleware/hono/tsconfig.json @@ -13,8 +13,14 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ] } } diff --git a/packages/middleware/node/tsconfig.json b/packages/middleware/node/tsconfig.json index d0f212f4fb..6c35df5027 100644 --- a/packages/middleware/node/tsconfig.json +++ b/packages/middleware/node/tsconfig.json @@ -11,9 +11,15 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"] } } diff --git a/packages/server-legacy/tsconfig.json b/packages/server-legacy/tsconfig.json index e2886239b8..832ad0ea9e 100644 --- a/packages/server-legacy/tsconfig.json +++ b/packages/server-legacy/tsconfig.json @@ -7,6 +7,7 @@ "*": ["./*"], "@modelcontextprotocol/core-internal": ["./node_modules/@modelcontextprotocol/core-internal/src/index.ts"], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], + "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], "@modelcontextprotocol/core-internal/public": ["./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts"] } } diff --git a/packages/server/package.json b/packages/server/package.json index 652683ed0a..6894825a30 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -40,6 +40,26 @@ "default": "./dist/stdio.cjs" } }, + "./ext/skills": { + "import": { + "types": "./dist/ext/skills/index.d.mts", + "default": "./dist/ext/skills/index.mjs" + }, + "require": { + "types": "./dist/ext/skills/index.d.cts", + "default": "./dist/ext/skills/index.cjs" + } + }, + "./ext/skills/node": { + "import": { + "types": "./dist/ext/skills/node.d.mts", + "default": "./dist/ext/skills/node.mjs" + }, + "require": { + "types": "./dist/ext/skills/node.d.cts", + "default": "./dist/ext/skills/node.cjs" + } + }, "./validators/ajv": { "import": { "types": "./dist/validators/ajv.d.mts", @@ -107,6 +127,12 @@ "types": "./dist/index.d.mts", "typesVersions": { "*": { + "ext/skills": [ + "dist/ext/skills/index.d.mts" + ], + "ext/skills/node": [ + "dist/ext/skills/node.d.mts" + ], "validators/ajv": [ "dist/validators/ajv.d.mts" ], diff --git a/packages/server/src/ext/skills/catalogBuild.ts b/packages/server/src/ext/skills/catalogBuild.ts new file mode 100644 index 0000000000..1eb7873974 --- /dev/null +++ b/packages/server/src/ext/skills/catalogBuild.ts @@ -0,0 +1,280 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { DIRECTORY_MIME_TYPE } from '@modelcontextprotocol/core/ext/skills'; +import type { Resource } from '@modelcontextprotocol/core-internal'; +import type { SkillFileInput } from '@modelcontextprotocol/core-internal/ext/skills'; +import { + buildSkillEntry, + compareUris, + isRoundTripUri, + joinUri, + mimeTypeFor, + sha256Digest, + validateSkillEntry +} from '@modelcontextprotocol/core-internal/ext/skills'; + +import { SkillCatalogError } from './errors'; +import type { SkillFileEntry, SkillFiles, SkillWarning } from './types'; + +export interface CatalogFile { + readonly path: string; + readonly uri: string; + readonly size: number; + readonly digest: string; + readonly mimeType: string; +} + +type Hashed = Pick; + +// Everything a catalog serves, built once per load or reload: metadata only, never file contents. +export interface CatalogSnapshot { + readonly ordered: readonly Skill[]; + readonly skills: ReadonlyMap; + readonly files: ReadonlyMap; + readonly dirs: ReadonlyMap; + readonly listed: readonly Resource[]; +} + +export interface CatalogBuildOptions { + readonly prefix: readonly string[]; + readonly limits: { readonly files: number; readonly bytes: number }; + readonly strict: boolean; + readonly warn: (warning: SkillWarning) => void; +} + +interface TreeEntry { + readonly path: string; + readonly kind: SkillFileEntry['kind']; +} + +interface BuiltSkill { + readonly dir: string; + readonly skill: Skill; + readonly files: readonly CatalogFile[]; + readonly manifestSize: number; + // Every entry below the skill's folder, nested skills' included, in tree order. + readonly inside: readonly TreeEntry[]; +} + +type UriOf = (path: string) => string; + +const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1); + +const dirname = (path: string): string => { + const slash = path.lastIndexOf('/'); + return slash === -1 ? '' : path.slice(0, slash); +}; + +const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +const NOT_FOLLOWED = 'symlinks and other special files are not followed; copy the file instead'; + +// Any non-directory named exactly `SKILL.md` marks a skill, so a symlinked one is reported rather than ignored. +const isManifest = (entry: TreeEntry): boolean => entry.kind !== 'directory' && basename(entry.path) === 'SKILL.md'; + +function append(lists: Map, key: K, value: V): void { + const list = lists.get(key); + if (list === undefined) lists.set(key, [value]); + else list.push(value); +} + +// `undefined` when a name in `path` has no URI form (a lone surrogate, or no round trip). +function representableUri(uriOf: UriOf, path: string): string | undefined { + let uri: string; + try { + uri = uriOf(path); + } catch (error) { + if (error instanceof URIError) return undefined; + throw error; + } + return isRoundTripUri(uri) ? uri : undefined; +} + +// Every non-hidden entry below the root, depth first; storage errors fail the build. +async function walk(files: SkillFiles): Promise { + const tree: TreeEntry[] = []; + const visit = async (dir: string): Promise => { + let entries: SkillFileEntry[]; + try { + entries = await files.list(dir); + } catch (error) { + throw new SkillCatalogError([{ path: dir === '' ? '.' : dir, message: `cannot be listed: ${errorText(error)}` }]); + } + for (const entry of entries.toSorted((a, b) => compareUris(a.name, b.name))) { + if (entry.name.startsWith('.')) continue; + const path = dir === '' ? entry.name : `${dir}/${entry.name}`; + tree.push({ path, kind: entry.kind }); + if (entry.kind === 'directory') await visit(path); + } + }; + await visit(''); + return tree; +} + +async function readFile(files: SkillFiles, path: string): Promise { + try { + return await files.read(path); + } catch (error) { + throw new SkillCatalogError([{ path, message: `cannot be read: ${errorText(error)}` }]); + } +} + +export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBuildOptions): Promise { + const tree = await walk(files); + const problems: SkillWarning[] = []; + const report = (warning: SkillWarning): void => { + if (options.strict) problems.push(warning); + else options.warn(warning); + }; + const uriOf: UriOf = path => joinUri('skill://', [...options.prefix, ...path.split('/')]); + + for (const entry of tree) { + const name = basename(entry.path); + if (entry.kind === 'file' && name !== 'SKILL.md' && name.toLowerCase() === 'skill.md') { + options.warn({ path: entry.path, message: 'did you mean SKILL.md? File names are case-sensitive' }); + } + } + + // Collect each skill's entries, nested skills' included; a special file outside every skill is reported here. + const skillDirs = new Set(tree.filter(entry => isManifest(entry)).map(entry => dirname(entry.path))); + const insideOf = new Map(); + for (const entry of tree) { + let inSkill = false; + for (let dir = dirname(entry.path); dir !== ''; dir = dirname(dir)) { + if (!skillDirs.has(dir)) continue; + inSkill = true; + append(insideOf, dir, entry); + } + if (!inSkill && entry.kind === 'unsupported' && !isManifest(entry)) { + report({ path: entry.path, message: `${NOT_FOLLOWED} (skipped)` }); + } + } + + const built: BuiltSkill[] = []; + const hashed = new Map(); + // Deepest first, so each file is read and hashed once: by the innermost skill that holds it. + const manifests = tree.filter(entry => isManifest(entry)).toSorted((a, b) => b.path.split('/').length - a.path.split('/').length); + for (const entry of manifests) { + const dir = dirname(entry.path); + if (dir === '') { + report({ + path: entry.path, + message: 'a SKILL.md at the root has no skill path; point loadSkills (or your adapter) at its parent folder (skipped)' + }); + continue; + } + const skill = await buildOne(files, insideOf.get(dir) ?? [], dir, uriOf, options, report, hashed); + if (skill !== undefined) built.push(skill); + } + if (problems.length > 0) throw new SkillCatalogError(problems); + if (built.length === 0) options.warn({ path: '.', message: 'no skills found (a skill is a folder containing SKILL.md)' }); + return assemble(built, tree, uriOf); +} + +async function buildOne( + files: SkillFiles, + inside: readonly TreeEntry[], + dir: string, + uriOf: UriOf, + options: CatalogBuildOptions, + report: (warning: SkillWarning) => void, + hashed: Map +): Promise { + const unsupported = inside.find(entry => entry.kind === 'unsupported'); + if (unsupported !== undefined) { + report({ path: unsupported.path, message: `${NOT_FOLLOWED} (skill skipped)` }); + return undefined; + } + const manifestPath = `${dir}/SKILL.md`; + const manifest: SkillFileInput[] = []; + const catalogFiles: CatalogFile[] = []; + for (const entry of inside) { + // Folders too: an empty one's name appears only in its parent's directory listing. + const uri = representableUri(uriOf, entry.path); + if (uri === undefined) { + report({ path: entry.path, message: 'this name cannot be represented in a URI (skill skipped)' }); + return undefined; + } + if (entry.kind !== 'file') continue; + const own = entry.path === manifestPath; + let file = own ? undefined : hashed.get(entry.path); + if (file === undefined) { + const bytes = await readFile(files, entry.path); + if (own) manifest.push({ uri, bytes }); + file = { digest: await sha256Digest(bytes), size: bytes.byteLength }; + hashed.set(entry.path, file); + } + catalogFiles.push({ path: entry.path, uri, ...file, mimeType: mimeTypeFor(basename(entry.path)) }); + } + // Parse SKILL.md on its own, then validate the entry that lists every file's digest. + const parsed = await buildSkillEntry(uriOf(manifestPath), manifest); + const resources = catalogFiles.map(({ uri, digest, size }) => ({ uri, digest, size })).toSorted((a, b) => compareUris(a.uri, b.uri)); + const skill = parsed.skill === undefined ? undefined : { ...parsed.skill, resources }; + const problems = skill === undefined ? parsed.problems : validateSkillEntry(skill); + if (skill === undefined || problems.length > 0) { + for (const message of problems) report({ path: manifestPath, message: `${message} (skill skipped)` }); + return undefined; + } + let totalBytes = 0; + for (const file of catalogFiles) totalBytes += file.size; + if (catalogFiles.length > options.limits.files || totalBytes > options.limits.bytes) { + const remedy = options.strict ? 'Raise limits to allow it' : 'Set strict: true to fail instead, or raise limits'; + report({ + path: dir, + message: + `${catalogFiles.length} files / ${totalBytes} bytes exceeds the limit of ${options.limits.files} files / ` + + `${options.limits.bytes} bytes; some hosts may decline it. ${remedy}` + }); + } + const manifestSize = catalogFiles.find(file => file.path === manifestPath)?.size ?? 0; + return { dir, skill, files: catalogFiles, manifestSize, inside }; +} + +function deepFreeze(value: T): T { + if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) { + Object.freeze(value); + for (const item of Object.values(value)) deepFreeze(item); + } + return value; +} + +function assemble(built: readonly BuiltSkill[], tree: readonly TreeEntry[], uriOf: UriOf): CatalogSnapshot { + // Frozen: entries are served unchecked and handed out by `entries`, so an edit must throw, not reach clients. + const ordered = Object.freeze(built.map(item => deepFreeze(item.skill)).toSorted((a, b) => compareUris(a.uri, b.uri))); + const files = new Map(); + const dirPaths = new Set(); + for (const item of built) { + for (const file of item.files) files.set(file.uri, file); + dirPaths.add(item.dir); + for (const entry of item.inside) { + if (entry.kind === 'directory') dirPaths.add(entry.path); + } + } + const childrenOf = new Map(); + for (const entry of tree) append(childrenOf, dirname(entry.path), entry); + const dirs = new Map(); + for (const dirPath of dirPaths) { + const children: Resource[] = []; + for (const entry of childrenOf.get(dirPath) ?? []) { + if (entry.kind === 'directory') { + children.push({ uri: uriOf(entry.path), name: basename(entry.path), mimeType: DIRECTORY_MIME_TYPE }); + continue; + } + const file = files.get(uriOf(entry.path)); + if (entry.kind === 'file' && file !== undefined) { + children.push({ uri: file.uri, name: basename(entry.path), mimeType: file.mimeType, size: file.size }); + } + } + children.sort((a, b) => compareUris(a.uri, b.uri)); + dirs.set(uriOf(dirPath), children); + } + const listed = built + .map(item => ({ + uri: item.skill.uri, + name: item.skill.frontmatter.name, + description: item.skill.frontmatter.description, + mimeType: 'text/markdown', + size: item.manifestSize + })) + .toSorted((a, b) => compareUris(a.uri, b.uri)); + return { ordered, skills: new Map(ordered.map(skill => [skill.uri, skill])), files, dirs, listed }; +} diff --git a/packages/server/src/ext/skills/catalogSource.ts b/packages/server/src/ext/skills/catalogSource.ts new file mode 100644 index 0000000000..28940bed2d --- /dev/null +++ b/packages/server/src/ext/skills/catalogSource.ts @@ -0,0 +1,77 @@ +import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError } from '@modelcontextprotocol/core-internal'; +import { canonicalUri, decodeUtf8Lossless, sha256Digest } from '@modelcontextprotocol/core-internal/ext/skills'; + +import type { CatalogSnapshot } from './catalogBuild'; +import { trustedSources } from './internal'; +import { paginate } from './paging'; +import type { SkillFiles, SkillReadResult, SkillSource, SkillWarning } from './types'; + +// `reload()` swaps `current` in one assignment. +export interface CatalogState { + current: CatalogSnapshot; +} + +const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +// A trusted source: the SDK built and checked the snapshot's entries. +export function catalogSource( + state: CatalogState, + files: SkillFiles, + pageSize: number, + warn: (warning: SkillWarning) => void +): SkillSource { + const source: SkillSource = { + async list({ cursor }) { + const page = paginate(state.current.ordered, cursor, pageSize, skill => skill.uri); + return page.nextCursor === undefined ? { skills: page.items } : { skills: page.items, nextCursor: page.nextCursor }; + }, + async get(uri) { + const canonical = canonicalUri(uri); + return canonical === undefined ? undefined : state.current.skills.get(canonical); + }, + read: uri => readChecked(state.current, files, uri, warn), + async readDirectory(uri, { cursor }) { + const canonical = canonicalUri(uri); + const children = canonical === undefined ? undefined : state.current.dirs.get(canonical); + if (children === undefined) return; + const page = paginate(children, cursor, pageSize, child => child.uri); + return page.nextCursor === undefined ? { resources: page.items } : { resources: page.items, nextCursor: page.nextCursor }; + }, + async listResources() { + return [...state.current.listed]; + } + }; + trustedSources.add(source); + return source; +} + +// Reads a published file through the adapter and serves it only if it still matches its entry. +async function readChecked( + snapshot: CatalogSnapshot, + files: SkillFiles, + uri: string, + warn: (warning: SkillWarning) => void +): Promise { + const canonical = canonicalUri(uri); + const file = canonical === undefined ? undefined : snapshot.files.get(canonical); + if (file === undefined) { + if (canonical !== undefined && snapshot.dirs.has(canonical)) { + throw new ResourceNotFoundError(uri, `${uri} is a directory; use resources/directory/read`); + } + return undefined; + } + let bytes: Uint8Array; + try { + bytes = await files.read(file.path); + } catch (error) { + warn({ path: file.path, message: `could not be read: ${errorText(error)}` }); + throw new ProtocolError(ProtocolErrorCode.InternalError, `${uri} could not be read`); + } + if (bytes.byteLength !== file.size || (await sha256Digest(bytes)) !== file.digest) { + warn({ path: file.path, message: 'changed since the catalog was loaded; call reload()' }); + throw new ProtocolError(ProtocolErrorCode.InternalError, `${uri} changed since the skills catalog was loaded`); + } + // Spec §5: `text` whenever the bytes decode losslessly as UTF-8, whatever the MIME type; otherwise a base64 `blob`. + const text = decodeUtf8Lossless(bytes); + return text === undefined ? { bytes, mimeType: file.mimeType } : { text, mimeType: file.mimeType }; +} diff --git a/packages/server/src/ext/skills/errors.ts b/packages/server/src/ext/skills/errors.ts new file mode 100644 index 0000000000..63eab39e31 --- /dev/null +++ b/packages/server/src/ext/skills/errors.ts @@ -0,0 +1,48 @@ +import { brandedHasInstance, stampErrorBrands } from '@modelcontextprotocol/core-internal'; + +import type { SkillWarning } from './types'; + +/** + * Thrown when a skill catalog cannot be built: storage failures always, skill problems under `strict`. + * Branded, so `instanceof` also matches an instance from another bundled copy of the SDK. + */ +export class SkillCatalogError extends Error { + static { + Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SkillCatalogError' }); + } + + static override [Symbol.hasInstance](value: unknown): boolean { + return brandedHasInstance(this, value); + } + + /** + * Brand-based type guard: equivalent to `value instanceof this`, as an + * explicit static predicate (the axios/AWS-SDK `isInstance` style). Must be + * invoked on the class — in callback position write + * `v => SkillCatalogError.isInstance(v)`, not `.filter(SkillCatalogError.isInstance)`. + */ + static isInstance unknown>(this: T, value: unknown): value is InstanceType { + if (typeof this !== 'function') { + throw new TypeError( + 'isInstance must be called on the class (e.g. `SkillCatalogError.isInstance(value)`); for callbacks use `v => SkillCatalogError.isInstance(v)`' + ); + } + return brandedHasInstance(this, value); + } + + readonly problems: readonly SkillWarning[]; + + constructor(problems: readonly SkillWarning[]) { + const lines = problems.map(problem => ` ${problem.path}: ${problem.message}`).join('\n'); + super(`${problems.length} skill problem${problems.length === 1 ? '' : 's'}:\n${lines}`); + this.name = 'SkillCatalogError'; + this.problems = problems; + stampErrorBrands(this, new.target); + } +} + +// Stderr, so a warning never corrupts a stdio transport. +export function defaultWarn(warning: SkillWarning): void { + // eslint-disable-next-line no-console -- the documented default sink for skills warnings + console.warn(`[mcp-sdk] skills: ${warning.path}: ${warning.message}`); +} diff --git a/packages/server/src/ext/skills/extension.ts b/packages/server/src/ext/skills/extension.ts new file mode 100644 index 0000000000..123607f045 --- /dev/null +++ b/packages/server/src/ext/skills/extension.ts @@ -0,0 +1,382 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { + GetSkillRequestParamsSchema, + ListSkillsRequestParamsSchema, + ReadResourceDirectoryRequestParamsSchema, + RESOURCES_DIRECTORY_READ_METHOD, + SKILL_LIMITS, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD +} from '@modelcontextprotocol/core/ext/skills'; +import type { CacheHint, ListResourcesResult, ReadResourceResult, ServerContext } from '@modelcontextprotocol/core-internal'; +import { + assertValidCacheHint, + isModernProtocolVersion, + ProtocolError, + ProtocolErrorCode, + ResourceNotFoundError +} from '@modelcontextprotocol/core-internal'; +import { + canonicalUri, + encodeBase64, + joinUri, + skillEntryProblems, + trimTrailingSlashes +} from '@modelcontextprotocol/core-internal/ext/skills'; + +import type { McpServer } from '../../server/mcp'; +import { ResourceTemplate } from '../../server/mcp'; +import type { Server } from '../../server/server'; +import type { CatalogBuildOptions, CatalogSnapshot } from './catalogBuild'; +import { buildCatalogSnapshot } from './catalogBuild'; +import type { CatalogState } from './catalogSource'; +import { catalogSource } from './catalogSource'; +import { defaultWarn } from './errors'; +import { splitRelativePath } from './files'; +import { trustedSources } from './internal'; +import { resolvePageSize } from './paging'; +import type { + SkillCatalogChanges, + SkillCatalogOptions, + SkillFiles, + SkillReadResult, + SkillSource, + SkillSourceOptions, + SkillWarning +} from './types'; + +const SCHEME_URI = /^[a-z][\d+.a-z-]*:\/\//i; + +function describeEntry(value: unknown): string { + const uri = (value as { uri?: unknown } | null)?.uri; + return typeof uri === 'string' ? uri : '(entry without a uri)'; +} + +function toContents(uri: string, result: SkillReadResult): ReadResourceResult['contents'][number] { + const mimeType = result.mimeType === undefined ? {} : { mimeType: result.mimeType }; + return 'text' in result ? { uri, ...mimeType, text: result.text } : { uri, ...mimeType, blob: encodeBase64(result.bytes) }; +} + +// Servers with a skills extension attached: one per server, as SEP-3371 rejects a duplicate extension identifier. +const skillsServers = new WeakSet(); + +// Runs after a successful `attach`, by extension (`SkillCatalog` tracks servers); off the class so no subclass hooks it. +const afterAttach = new WeakMap void>(); + +const ONE_PER_SERVER = + 'another skills extension is already attached to this server; attach one per server ' + + '(to serve several folders or sources, combine them behind one SkillFiles adapter or SkillSource)'; + +/** + * The MCP Skills extension (SEP-2640) for one catalog of skills. One instance + * serves any number of servers: call {@linkcode SkillsExtension.attach} on each + * (before connecting). Build it from a {@linkcode SkillSource}, or with + * `SkillsExtension.load(files)` from storage. + */ +export class SkillsExtension { + readonly id = SKILLS_EXTENSION_ID; + readonly #source: SkillSource; + readonly #trusted: boolean; + readonly #baseRoot: string; + readonly #cacheHint: CacheHint | undefined; + readonly #cache: { ttlMs: number; cacheScope: 'public' | 'private' }; + readonly #strict: boolean; + readonly #warn: (warning: SkillWarning) => void; + readonly #attached = new WeakSet(); + + /** + * Builds a catalog from storage through a {@linkcode SkillFiles} adapter + * (`memorySkillFiles`, `nodeSkillFiles`, or your own). + */ + static load(files: SkillFiles, options?: SkillCatalogOptions): Promise { + return SkillCatalog.load(files, options); + } + + constructor(options: SkillSourceOptions) { + if (options.cacheHint !== undefined) assertValidCacheHint(options.cacheHint, 'skills cacheHint'); + const baseUri = options.baseUri ?? 'skill://'; + // Canonical, like the request URIs that the template and the listResources filter are matched against. + const canonical = SCHEME_URI.test(baseUri) ? canonicalUri(baseUri) : undefined; + if (canonical === undefined) throw new TypeError(`baseUri must be a "scheme://…" URI (got ${JSON.stringify(baseUri)})`); + this.#baseRoot = canonical.endsWith('://') ? canonical : `${trimTrailingSlashes(canonical)}/`; + this.#source = options.source; + this.#trusted = trustedSources.has(options.source); + this.#cacheHint = options.cacheHint; + this.#cache = { ttlMs: options.cacheHint?.ttlMs ?? 0, cacheScope: options.cacheHint?.cacheScope ?? 'private' }; + this.#strict = options.strict ?? false; + this.#warn = options.onWarning ?? defaultWarn; + } + + /** + * Serves these skills on `mcp`: registers the skill resource template and + * the skills methods, then declares the capability. Must run before + * connecting. Calling it again for the same server does nothing. One + * skills extension per server: attaching another one throws and leaves the + * server as it was, as does attaching to a server that already handles one + * of the skills methods. + */ + attach(mcp: McpServer): void { + if (this.#attached.has(mcp)) return; + if (mcp.isConnected()) throw new Error('attach skills before connecting the server'); + if (skillsServers.has(mcp)) throw new Error(ONE_PER_SERVER); + const server = mcp.server; + const directoryRead = this.#source.readDirectory !== undefined; + const list = this.#source.listResources === undefined ? undefined : (ctx: ServerContext) => this.#listResources(ctx); + server.assertCanSetRequestHandler(SKILLS_LIST_METHOD); + server.assertCanSetRequestHandler(SKILLS_GET_METHOD); + if (directoryRead) server.assertCanSetRequestHandler(RESOURCES_DIRECTORY_READ_METHOD); + // Check before changing anything; declare the capability last, so a server never advertises skills it does not serve. + mcp.registerResource( + SKILLS_EXTENSION_ID, + new ResourceTemplate(`${this.#baseRoot}{+path}`, { list }), + this.#cacheHint === undefined ? {} : { cacheHint: this.#cacheHint }, + (uri, _variables, ctx) => this.#read(uri, ctx) + ); + server.setRequestHandler(SKILLS_LIST_METHOD, { params: ListSkillsRequestParamsSchema }, (params, ctx) => + this.#list(server, params.cursor, ctx) + ); + server.setRequestHandler(SKILLS_GET_METHOD, { params: GetSkillRequestParamsSchema }, (params, ctx) => + this.#get(server, params.uri, ctx) + ); + if (directoryRead) { + server.setRequestHandler(RESOURCES_DIRECTORY_READ_METHOD, { params: ReadResourceDirectoryRequestParamsSchema }, (params, ctx) => + this.#readDirectory(server, params.uri, params.cursor, ctx) + ); + } + server.registerCapabilities({ extensions: { [SKILLS_EXTENSION_ID]: directoryRead ? { directoryRead: true } : {} } }); + this.#attached.add(mcp); + skillsServers.add(mcp); + afterAttach.get(this)?.(mcp); + } + + #cacheFields(server: Server): { ttlMs?: number; cacheScope?: 'public' | 'private' } { + return isModernProtocolVersion(server.getNegotiatedProtocolVersion() ?? '') ? { ...this.#cache } : {}; + } + + #keepValid(skills: readonly Skill[]): Skill[] { + if (this.#trusted) return [...skills]; + const kept: Skill[] = []; + for (const candidate of skills) { + const problems = skillEntryProblems(candidate); + if (problems.length === 0) { + kept.push(candidate); + continue; + } + const message = `invalid skill entry from the source: ${problems.join('; ')}`; + if (this.#strict) throw new ProtocolError(ProtocolErrorCode.InternalError, `${describeEntry(candidate)}: ${message}`); + this.#warn({ path: describeEntry(candidate), message: `${message} (dropped from skills/list)` }); + } + return kept; + } + + async #list(server: Server, cursor: string | undefined, ctx: ServerContext) { + const page = await this.#source.list(cursor === undefined ? {} : { cursor }, ctx); + return { + skills: this.#keepValid(page.skills), + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + ...this.#cacheFields(server) + }; + } + + async #get(server: Server, uri: string, ctx: ServerContext) { + const skill = await this.#source.get(uri, ctx); + if (skill === undefined) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `No skill is served at ${uri}`); + if (!this.#trusted) { + const problems = skillEntryProblems(skill); + if (problems.length > 0) { + const message = `invalid skill entry from the source: ${problems.join('; ')}`; + this.#warn({ path: uri, message }); + throw new ProtocolError(ProtocolErrorCode.InternalError, message); + } + } + if (canonicalUri(skill.uri) !== canonicalUri(uri)) { + throw new ProtocolError(ProtocolErrorCode.InternalError, `the skill source returned ${skill.uri} for ${uri}`); + } + return { skill, ...this.#cacheFields(server) }; + } + + async #read(uri: URL, ctx: ServerContext): Promise { + const result = await this.#source.read(uri.href, ctx); + if (result === undefined) throw new ResourceNotFoundError(uri.href); + return { contents: [toContents(uri.href, result)] }; + } + + async #readDirectory(server: Server, uri: string, cursor: string | undefined, ctx: ServerContext) { + const page = await this.#source.readDirectory!(uri, cursor === undefined ? {} : { cursor }, ctx); + if (page === undefined) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `${uri} is not a directory resource`); + return { + resources: page.resources, + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + ...this.#cacheFields(server) + }; + } + + async #listResources(ctx: ServerContext): Promise { + const resources = await this.#source.listResources!(ctx); + const kept = []; + for (const resource of resources) { + if (canonicalUri(resource.uri)?.startsWith(this.#baseRoot)) kept.push(resource); + else + this.#warn({ + path: resource.uri, + message: `listResources returned a URI outside ${this.#baseRoot}; dropped from resources/list` + }); + } + return { resources: kept }; + } +} + +interface CatalogInit { + readonly state: CatalogState; + readonly files: SkillFiles; + readonly build: CatalogBuildOptions; + readonly sourceOptions: SkillSourceOptions; +} + +// Fewest tracked servers at which `attach` drops references to collected ones; then again at twice the survivors. +const PRUNE_THRESHOLD = 1024; + +function resolveLimits(limits: SkillCatalogOptions['limits']): { files: number; bytes: number } { + const resolved = { files: limits?.files ?? SKILL_LIMITS.files, bytes: limits?.bytes ?? SKILL_LIMITS.bytes }; + for (const [name, value] of Object.entries(resolved)) { + if (value !== Number.POSITIVE_INFINITY && !(Number.isSafeInteger(value) && value > 0)) { + throw new RangeError(`limits.${name} must be a positive integer or Infinity (got ${value})`); + } + } + return resolved; +} + +// What `resources/list_changed` is about: which skills exist, and each one's name and description. +function listingKey(snapshot: CatalogSnapshot): string { + return JSON.stringify(snapshot.listed.map(resource => [resource.uri, resource.name, resource.description])); +} + +function diffSnapshots(previous: CatalogSnapshot, next: CatalogSnapshot): Omit { + const same = (uri: string): boolean => JSON.stringify(previous.skills.get(uri)) === JSON.stringify(next.skills.get(uri)); + return { + added: [...next.skills.keys()].filter(uri => !previous.skills.has(uri)), + removed: [...previous.skills.keys()].filter(uri => !next.skills.has(uri)), + changed: [...next.skills.keys()].filter(uri => previous.skills.has(uri) && !same(uri)) + }; +} + +/** + * A catalog the SDK built from storage. It holds metadata only: every file + * read goes through the adapter and is checked against the published entry. + */ +export class SkillCatalog extends SkillsExtension { + readonly #state: CatalogState; + readonly #files: SkillFiles; + readonly #build: CatalogBuildOptions; + readonly #servers = new Set>(); + #pruneAt = PRUNE_THRESHOLD; + #queue: Promise = Promise.resolve(); + + private constructor(init: CatalogInit) { + super(init.sourceOptions); + this.#state = init.state; + this.#files = init.files; + this.#build = init.build; + afterAttach.set(this, mcp => this.#track(mcp)); + } + + static override async load(files: SkillFiles, options: SkillCatalogOptions = {}): Promise { + const pageSize = resolvePageSize(options.pageSize); + const build: CatalogBuildOptions = { + prefix: options.prefix === undefined ? [] : splitRelativePath(options.prefix, 'prefix'), + limits: resolveLimits(options.limits), + strict: options.strict ?? false, + warn: options.onWarning ?? defaultWarn + }; + const state: CatalogState = { current: await buildCatalogSnapshot(files, build) }; + return new SkillCatalog({ + state, + files, + build, + sourceOptions: { + source: catalogSource(state, files, pageSize, build.warn), + baseUri: build.prefix.length === 0 ? 'skill://' : joinUri('skill://', build.prefix), + cacheHint: options.cacheHint, + strict: options.strict, + onWarning: build.warn + } + }); + } + + /** The skills this catalog serves, sorted by URI. Frozen (deeply): they are exactly what clients receive. */ + get entries(): readonly Skill[] { + return this.#state.current.ordered; + } + + /** + * Scans the storage again and swaps the new catalog in at once. All or + * nothing: on failure the old catalog keeps serving. Overlapping calls run + * one after another. + * + * When skills are added or removed, or a `SKILL.md`'s name or description + * changes, the result has `listChanged: true` and `resources/list_changed` + * goes out through every attached server that is still connected (stdio, + * stateful 2025-11-25 HTTP sessions). Servers that `createMcpHandler` builds per + * request are closed by then, so publish the change on the handler: + * `if (changes.listChanged) handler.notify.resourcesChanged()`. Where the + * runtime has no `WeakRef` (Cloudflare Workers with a compatibility date + * before 2025-05-05), attached servers are not tracked and nothing is sent. + */ + reload(): Promise { + const run = this.#queue.then(() => this.#reloadNow()); + this.#queue = run.then( + () => {}, + () => {} + ); + return run; + } + + #track(mcp: McpServer): void { + // Without WeakRef, holding servers strongly would keep every per-request server alive; track none instead. + if (typeof WeakRef !== 'function') return; + if (this.#servers.size >= this.#pruneAt) { + for (const reference of this.#servers) if (reference.deref() === undefined) this.#servers.delete(reference); + this.#pruneAt = Math.max(PRUNE_THRESHOLD, 2 * this.#servers.size); + } + this.#servers.add(new WeakRef(mcp)); + } + + async #reloadNow(): Promise { + const previous = this.#state.current; + const next = await buildCatalogSnapshot(this.#files, this.#build); + this.#state.current = next; + const listChanged = listingKey(previous) !== listingKey(next); + if (listChanged) this.#notify(); + return { ...diffSnapshots(previous, next), listChanged }; + } + + // A send that fails becomes a warning: never an unhandled rejection, and never a reason to skip the other servers. + #notify(): void { + for (const reference of this.#servers) { + const mcp = reference.deref(); + if (mcp === undefined) { + this.#servers.delete(reference); + continue; + } + if (!mcp.isConnected()) continue; + const sessionId = mcp.server.transport?.sessionId; + try { + // `Server.sendResourceListChanged` is async; `Promise.resolve` also covers a synchronous return. + Promise.resolve(mcp.server.sendResourceListChanged()).catch((error: unknown) => this.#notifyFailed(sessionId, error)); + } catch (error) { + this.#notifyFailed(sessionId, error); + } + } + } + + #notifyFailed(sessionId: string | undefined, error: unknown): void { + const to = sessionId === undefined ? '' : ` to session ${sessionId}`; + const reason = error instanceof Error ? error.message : String(error); + try { + this.#build.warn({ path: 'resources/list_changed', message: `could not be sent${to}: ${reason}` }); + } catch { + // A throwing onWarning must not escape either: it would reject reload() or go unhandled. + } + } +} diff --git a/packages/server/src/ext/skills/files.ts b/packages/server/src/ext/skills/files.ts new file mode 100644 index 0000000000..8b93955ddf --- /dev/null +++ b/packages/server/src/ext/skills/files.ts @@ -0,0 +1,68 @@ +import { encodeUtf8 } from '@modelcontextprotocol/core-internal/ext/skills'; + +import type { SkillFileEntry, SkillFiles } from './types'; + +// Splits a relative `/`-separated path, rejecting `..` and empty paths. +export function splitRelativePath(path: string, label: string): string[] { + const segments = path.split('/').filter(segment => segment !== '' && segment !== '.'); + if (segments.length === 0 || segments.includes('..')) { + throw new TypeError(`${label} must be a relative path without ".." (got ${JSON.stringify(path)})`); + } + return segments; +} + +// A memory record's directories and files, by normalised path. +interface MemoryIndex { + // Children of every directory (`''` is the root), in record order. + readonly dirs: ReadonlyMap>; + // The record key that holds each file. + readonly files: ReadonlyMap; +} + +function indexRecord(keys: readonly string[]): MemoryIndex { + const dirs = new Map>(); + const files = new Map(); + for (const key of keys) { + const segments = splitRelativePath(key, 'file path'); + let dir = ''; + for (const [depth, name] of segments.entries()) { + let children = dirs.get(dir); + if (children === undefined) { + children = new Map(); + dirs.set(dir, children); + } + children.set(name, { name, kind: depth < segments.length - 1 ? 'directory' : 'file' }); + dir = dir === '' ? name : `${dir}/${name}`; + } + files.set(dir, key); + } + return { dirs, files }; +} + +/** + * An in-memory adapter. The record is re-read whenever a walk starts — `list('')` — so `reload()` sees later changes. + * Other calls use the index from that read (built on first use if no walk has started), so their cost does not grow + * with the record. File contents are always read live. + */ +export function memorySkillFiles(files: Record): SkillFiles { + let index: MemoryIndex | undefined; + const reindex = (): MemoryIndex => { + index = indexRecord(Object.keys(files)); + return index; + }; + return { + async list(dir) { + const children = (dir === '' ? reindex() : (index ?? reindex())).dirs.get(dir); + if (children !== undefined) return Array.from(children.values(), entry => ({ ...entry })); + if (dir === '') return []; + throw new Error(`no such directory: ${dir}`); + }, + async read(path) { + // Keys written as `./a/b` or `a//b` are found through the index. + const key = Object.hasOwn(files, path) ? path : (index ?? reindex()).files.get(path); + const content = key === undefined ? undefined : files[key]; + if (content === undefined) throw new Error(`no such file: ${path}`); + return typeof content === 'string' ? encodeUtf8(content) : content; + } + }; +} diff --git a/packages/server/src/ext/skills/fsPath.ts b/packages/server/src/ext/skills/fsPath.ts new file mode 100644 index 0000000000..82920ca943 --- /dev/null +++ b/packages/server/src/ext/skills/fsPath.ts @@ -0,0 +1,23 @@ +import { splitRelativePath } from './files'; + +// The parts of `node:path` the containment rule needs: `path`, `path.win32` and `path.posix` all fit. +export interface PathRules { + readonly sep: string; + join(...paths: string[]): string; + relative(from: string, to: string): string; + isAbsolute(path: string): boolean; +} + +const escapes = (relative: string): TypeError => + new TypeError(`path must be a relative path that stays inside the skills folder (got ${JSON.stringify(relative)})`); + +// Where adapter path `relative` is under `base`; a TypeError for any path that could leave it, by the platform's `rules`. +export function fsPathWithin(rules: PathRules, base: string, relative: string): string { + if (relative === '') return base; + const segments = splitRelativePath(relative, 'path'); + if (segments.some(segment => segment.split(rules.sep).includes('..'))) throw escapes(relative); + const full = rules.join(base, ...segments); + const inside = rules.relative(base, full); + if (inside === '..' || inside.startsWith(`..${rules.sep}`) || rules.isAbsolute(inside)) throw escapes(relative); + return full; +} diff --git a/packages/server/src/ext/skills/index.ts b/packages/server/src/ext/skills/index.ts new file mode 100644 index 0000000000..e48b9fc347 --- /dev/null +++ b/packages/server/src/ext/skills/index.ts @@ -0,0 +1,41 @@ +// @modelcontextprotocol/server/ext/skills +// +// Server side of the MCP Skills extension (SEP-2640). Adapted, with +// attribution, from the Apache-2.0 `@olaservo/ext-skills` prototype +// (modelcontextprotocol/ext-skills#71), typescript-sdk#2818 and #2972. + +export { SkillCatalogError } from './errors'; +export { SkillCatalog, SkillsExtension } from './extension'; +export { memorySkillFiles } from './files'; +export { skillEntry } from './skillEntry'; +export type { + SkillCatalogChanges, + SkillCatalogOptions, + SkillFileEntry, + SkillFiles, + SkillReadResult, + SkillsOptions, + SkillSource, + SkillSourceOptions, + SkillWarning +} from './types'; +export type { + DirectoryResource, + GetSkillResult, + ListSkillsResult, + ReadResourceDirectoryResult, + Skill, + SkillCacheScope, + SkillFrontmatter, + SkillResourceEntry, + SkillResources, + SkillsCapability +} from '@modelcontextprotocol/core/ext/skills'; +export { + DIRECTORY_MIME_TYPE, + RESOURCES_DIRECTORY_READ_METHOD, + SKILL_LIMITS, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD +} from '@modelcontextprotocol/core/ext/skills'; diff --git a/packages/server/src/ext/skills/internal.ts b/packages/server/src/ext/skills/internal.ts new file mode 100644 index 0000000000..7483ccb09c --- /dev/null +++ b/packages/server/src/ext/skills/internal.ts @@ -0,0 +1,4 @@ +import type { SkillSource } from './types'; + +// Sources whose entries the SDK built itself, so they are not re-validated per request. +export const trustedSources = new WeakSet(); diff --git a/packages/server/src/ext/skills/node.ts b/packages/server/src/ext/skills/node.ts new file mode 100644 index 0000000000..12cb203f23 --- /dev/null +++ b/packages/server/src/ext/skills/node.ts @@ -0,0 +1,67 @@ +// @modelcontextprotocol/server/ext/skills/node +// +// Node.js storage for the MCP Skills extension (SEP-2640): serve a folder of +// skills. The only module of the skills extension that imports Node built-ins. + +import type { Dirent } from 'node:fs'; +import { constants } from 'node:fs'; +import { open, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +import type { SkillCatalog } from './extension'; +import { SkillsExtension } from './extension'; +import { fsPathWithin } from './fsPath'; +import type { SkillCatalogOptions, SkillFileEntry, SkillFiles } from './types'; + +function kindOf(entry: Dirent): SkillFileEntry['kind'] { + if (entry.isFile()) return 'file'; + if (entry.isDirectory()) return 'directory'; + return 'unsupported'; +} + +/** + * A {@linkcode SkillFiles} adapter over a folder. `list` reports symlinks and + * other special files as `unsupported`, so a catalog skips the skill that + * contains one. `read` refuses anything that is not a regular file, and a + * symlink as the final path component where the platform has `O_NOFOLLOW`; + * symlinked folders earlier in a path passed to it directly are not checked. + * Both throw a `TypeError` for a path that could leave `root` (a `..` + * segment, also `..\` on Windows). + */ +export function nodeSkillFiles(root: string): SkillFiles { + const base = path.resolve(root); + const toFsPath = (relative: string): string => fsPathWithin(path, base, relative); + return { + async list(dir) { + const entries = await readdir(toFsPath(dir), { withFileTypes: true }); + return entries.map(entry => ({ name: entry.name, kind: kindOf(entry) })); + }, + async read(relative) { + // O_NOFOLLOW (where the platform has it) refuses a final symlink even if one appeared after listing. + const handle = await open(toFsPath(relative), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const stats = await handle.stat(); + if (!stats.isFile()) throw new Error(`${relative} is not a regular file`); + const buffer = await handle.readFile(); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } finally { + await handle.close(); + } + } + }; +} + +/** + * Loads every skill under `root` (a folder of skill folders, each with a + * `SKILL.md`) into a {@linkcode SkillCatalog}. Same as + * `SkillsExtension.load(nodeSkillFiles(root), options)`. + * + * @example + * ```ts + * const skills = await loadSkills('./skills'); + * skills.attach(mcp); + * ``` + */ +export function loadSkills(root: string, options?: SkillCatalogOptions): Promise { + return SkillsExtension.load(nodeSkillFiles(root), options); +} diff --git a/packages/server/src/ext/skills/paging.ts b/packages/server/src/ext/skills/paging.ts new file mode 100644 index 0000000000..2b4dc7bd68 --- /dev/null +++ b/packages/server/src/ext/skills/paging.ts @@ -0,0 +1,49 @@ +import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core-internal'; +import { compareUris, decodeBase64, decodeUtf8Lossless, encodeBase64, encodeUtf8 } from '@modelcontextprotocol/core-internal/ext/skills'; + +const CURSOR_PREFIX = 'after:'; + +// An opaque, URL-safe cursor meaning "after this URI". +export function encodeCursor(uri: string): string { + return encodeBase64(encodeUtf8(CURSOR_PREFIX + uri)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); +} + +// The URI a cursor points after, or `undefined` when it is not one of ours. +export function decodeCursor(cursor: string): string | undefined { + try { + const base64 = cursor.replaceAll('-', '+').replaceAll('_', '/'); + const decoded = decodeUtf8Lossless(decodeBase64(base64 + '='.repeat((4 - (base64.length % 4)) % 4))); + return decoded?.startsWith(CURSOR_PREFIX) ? decoded.slice(CURSOR_PREFIX.length) : undefined; + } catch { + return undefined; + } +} + +// One page of URI-sorted `items` after `cursor`; entries added or removed elsewhere never shift the boundary. +export function paginate( + items: readonly T[], + cursor: string | undefined, + pageSize: number, + key: (item: T) => string +): { items: T[]; nextCursor?: string } { + let start = 0; + if (cursor !== undefined) { + const after = decodeCursor(cursor); + if (after === undefined) throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid cursor'); + start = items.findIndex(item => compareUris(key(item), after) > 0); + if (start === -1) start = items.length; + } + const end = Math.min(items.length, start + pageSize); + const page = items.slice(start, end); + const last = page.at(-1); + return end < items.length && last !== undefined ? { items: page, nextCursor: encodeCursor(key(last)) } : { items: page }; +} + +export function resolvePageSize(pageSize: number | undefined): number { + if (pageSize === undefined) return 100; + if (pageSize === Number.POSITIVE_INFINITY || (Number.isSafeInteger(pageSize) && pageSize > 0)) return pageSize; + throw new RangeError(`pageSize must be a positive integer or Infinity (got ${pageSize})`); +} diff --git a/packages/server/src/ext/skills/skillEntry.ts b/packages/server/src/ext/skills/skillEntry.ts new file mode 100644 index 0000000000..f5f29661b6 --- /dev/null +++ b/packages/server/src/ext/skills/skillEntry.ts @@ -0,0 +1,28 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { buildSkillEntry, encodeUtf8, joinUri } from '@modelcontextprotocol/core-internal/ext/skills'; + +import { SkillCatalogError } from './errors'; +import { splitRelativePath } from './files'; + +/** + * Builds a valid entry — digests, sizes, frontmatter, rule checks — from one + * skill's files. Keys are paths under the skill's folder (`SKILL.md`, + * `references/GUIDE.md`). For `SkillSource` authors who publish digests. + */ +export async function skillEntry( + skillPath: string, + files: Record, + options: { baseUri?: string } = {} +): Promise { + const segments = splitRelativePath(skillPath, 'skillPath'); + const baseUri = options.baseUri ?? 'skill://'; + const inputs = Object.entries(files).map(([path, content]) => ({ + uri: joinUri(baseUri, [...segments, ...splitRelativePath(path, 'file path')]), + bytes: typeof content === 'string' ? encodeUtf8(content) : content + })); + const { skill, problems } = await buildSkillEntry(joinUri(baseUri, [...segments, 'SKILL.md']), inputs); + if (skill === undefined || problems.length > 0) { + throw new SkillCatalogError(problems.map(message => ({ path: skillPath, message }))); + } + return skill; +} diff --git a/packages/server/src/ext/skills/types.ts b/packages/server/src/ext/skills/types.ts new file mode 100644 index 0000000000..69ecd5540e --- /dev/null +++ b/packages/server/src/ext/skills/types.ts @@ -0,0 +1,77 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import type { CacheHint, Resource, ServerContext } from '@modelcontextprotocol/core-internal'; + +/** A problem found while building or serving skills. `path` names the file, folder or URI. */ +export interface SkillWarning { + path: string; + message: string; +} + +/** A skill file's content: text (sent as `text`) or bytes (sent as a base64 `blob`). */ +export type SkillReadResult = { text: string; mimeType?: string } | { bytes: Uint8Array; mimeType?: string }; + +/** A catalog you build yourself: per-user, generated per request, too large to enumerate, or "dynamic". */ +export interface SkillSource { + /** One page of entries; an empty or partial listing is allowed. */ + list(params: { cursor?: string }, ctx: ServerContext): Promise<{ skills: Skill[]; nextCursor?: string }>; + /** The entry whose `SKILL.md` is at `uri`, listed or not; `undefined` when none is served. */ + get(uri: string, ctx: ServerContext): Promise; + /** A skill file's content; `undefined` when no such file is served. */ + read(uri: string, ctx: ServerContext): Promise; + /** Direct children of a directory resource; `undefined` when `uri` is not one. Implementing it declares `directoryRead`. */ + readDirectory?( + uri: string, + params: { cursor?: string }, + ctx: ServerContext + ): Promise<{ resources: Resource[]; nextCursor?: string } | undefined>; + /** What appears in `resources/list` (a small set; not paged). Absent: nothing is listed. */ + listResources?(ctx: ServerContext): Promise; +} + +export interface SkillsOptions { + /** `ttlMs` / `cacheScope` for skills/list, skills/get, directory reads and skill files (2026-07-28). Default `0` / `private`. */ + cacheHint?: CacheHint; + /** Turn problems that are otherwise warnings into errors. Default `false`. */ + strict?: boolean; + /** Where warnings go. Default: `console.warn` (stderr). */ + onWarning?: (warning: SkillWarning) => void; +} + +export interface SkillSourceOptions extends SkillsOptions { + source: SkillSource; + /** Every URI the source serves starts with this. Default `skill://`. */ + baseUri?: string; +} + +/** One child of a directory. `unsupported` marks symlinks and other special files; the catalog skips the skill that contains one. */ +export interface SkillFileEntry { + name: string; + kind: 'file' | 'directory' | 'unsupported'; +} + +/** Storage adapter for catalogs: two operations over relative, `/`-separated paths (`''` is the root). */ +export interface SkillFiles { + list(dir: string): Promise; + read(path: string): Promise; +} + +export interface SkillCatalogOptions extends SkillsOptions { + /** Organisational prefix: `'acme'` serves `skill://acme//…`. */ + prefix?: string; + /** Entries per `skills/list` and directory page: a positive integer or `Infinity`. Default 100. */ + pageSize?: number; + /** Publication limits per skill. Default `SKILL_LIMITS` (512 files, 16 MiB). Beyond them: a warning, or an error under `strict`. */ + limits?: { files?: number; bytes?: number }; +} + +/** What a `reload()` changed, as `SKILL.md` URIs. */ +export interface SkillCatalogChanges { + added: string[]; + removed: string[]; + changed: string[]; + /** + * Whether what clients list changed: a skill added or removed, or a `SKILL.md`'s name or description. When + * `true`, tell clients (`resources/list_changed`); behind `createMcpHandler`, call `handler.notify.resourcesChanged()`. + */ + listChanged: boolean; +} diff --git a/packages/server/test/ext/skills/catalog.test.ts b/packages/server/test/ext/skills/catalog.test.ts new file mode 100644 index 0000000000..6f3894ffa2 --- /dev/null +++ b/packages/server/test/ext/skills/catalog.test.ts @@ -0,0 +1,360 @@ +import { createHash } from 'node:crypto'; + +import type { Skill, SkillResourceEntry } from '@modelcontextprotocol/core/ext/skills'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SkillCatalogOptions, SkillWarning } from '../../../src/ext/skills/index'; +import { memorySkillFiles, SkillCatalogError, SkillsExtension } from '../../../src/ext/skills/index'; +import { ESCALATION, fixtureFiles, GIT, GIT_SKILL_MD, LOGO, PNG, PURCHASE_ORDER, REFUNDS, REFUNDS_SKILL_MD, REMBOURSE } from './fixtures'; +import { errorOf, LEGACY, newServer, resultOf } from './harness'; + +async function loaded(options: SkillCatalogOptions = {}) { + const files = fixtureFiles(); + const warnings: SkillWarning[] = []; + const catalog = await SkillsExtension.load(memorySkillFiles(files), { onWarning: warning => warnings.push(warning), ...options }); + const mcp = newServer(); + catalog.attach(mcp); + return { catalog, mcp, warnings, files }; +} + +const skillUris = (result: Record): string[] => (result['skills'] as Skill[]).map(skill => skill.uri); +const firstContent = (result: Record): Record | undefined => + (result['contents'] as Array>)[0]; +const NEW_SKILL = `--- +name: aaa +description: Sorts first. +--- +`; + +describe('SkillCatalog serving', () => { + it('loads every skill sorted by URI and declares directoryRead with the skill:// template', async () => { + const { catalog, mcp } = await loaded(); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(mcp.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': { directoryRead: true } }); + expect(await resultOf(mcp, 'resources/templates/list')).toMatchObject({ + resourceTemplates: [{ name: 'io.modelcontextprotocol/skills', uriTemplate: 'skill://{+path}' }] + }); + }); + + it('serves files under a prefix that needs strict encoding in the authority', async () => { + const { catalog, mcp } = await loaded({ prefix: 'acme:prod' }); + const git = 'skill://acme%3Aprod/git-workflow/SKILL.md'; + expect(catalog.entries.map(skill => skill.uri)).toContain(git); + expect(catalog.entries).toHaveLength(3); + const review = new URL('references/REVIEW.md', git).href; + expect(await resultOf(mcp, 'resources/read', { uri: review })).toMatchObject({ contents: [{ uri: review, text: '# Review\n' }] }); + }); + + it('lists only SKILL.md resources in resources/list', async () => { + const { mcp } = await loaded(); + expect(await resultOf(mcp, 'resources/list')).toMatchObject({ + resources: [ + { uri: REFUNDS, name: 'refunds', mimeType: 'text/markdown' }, + { uri: ESCALATION, name: 'escalation', mimeType: 'text/markdown' }, + { uri: GIT, name: 'git-workflow', mimeType: 'text/markdown' } + ] + }); + }); + + it('pages skills/list with position cursors that survive a reload', async () => { + const { mcp, catalog, files } = await loaded({ pageSize: 2 }); + const first = await resultOf(mcp, 'skills/list'); + expect(skillUris(first)).toEqual([REFUNDS, ESCALATION]); + files['aaa/SKILL.md'] = NEW_SKILL; + await catalog.reload(); + const second = await resultOf(mcp, 'skills/list', { cursor: first['nextCursor'] }); + expect(skillUris(second)).toEqual([GIT]); + expect(second).not.toHaveProperty('nextCursor'); + }); + + it('rejects bad cursors and bad page sizes, and serves one page with Infinity', async () => { + const paged = await loaded(); + expect(await errorOf(paged.mcp, 'skills/list', { cursor: 'garbage' })).toMatchObject({ code: -32_602 }); + await expect(SkillsExtension.load(memorySkillFiles(fixtureFiles()), { pageSize: 0 })).rejects.toThrow(RangeError); + const unpaged = await loaded({ pageSize: Number.POSITIVE_INFINITY }); + expect(skillUris(await resultOf(unpaged.mcp, 'skills/list'))).toHaveLength(3); + }); + + it('answers skills/get for served skills and -32602 otherwise', async () => { + const { mcp } = await loaded(); + expect(await resultOf(mcp, 'skills/get', { uri: ESCALATION })).toMatchObject({ skill: { uri: ESCALATION } }); + expect(await errorOf(mcp, 'skills/get', { uri: 'skill://nope/SKILL.md' })).toMatchObject({ code: -32_602 }); + }); + + it('serves text, binary and Windows-authored SKILL.md files byte for byte', async () => { + const { mcp, catalog } = await loaded(); + expect(await resultOf(mcp, 'resources/read', { uri: GIT })).toMatchObject({ + contents: [{ uri: GIT, mimeType: 'text/markdown', text: GIT_SKILL_MD }] + }); + const logo = firstContent(await resultOf(mcp, 'resources/read', { uri: LOGO })); + expect(Buffer.from(String(logo?.['blob']), 'base64')).toEqual(Buffer.from(PNG)); + const refunds = firstContent(await resultOf(mcp, 'resources/read', { uri: REFUNDS })); + expect(refunds?.['text']).toBe(REFUNDS_SKILL_MD); + const entry = catalog.entries.find(skill => skill.uri === REFUNDS); + const listed = (entry?.resources as SkillResourceEntry[]).find(resource => resource.uri === REFUNDS); + expect(listed?.digest).toBe(`sha256:${createHash('sha256').update(REFUNDS_SKILL_MD).digest('hex')}`); + }); + + it('serves valid UTF-8 as text whatever the MIME type', async () => { + const { mcp, catalog, files } = await loaded(); + files['git-workflow/LICENSE'] = 'MIT License\n'; + await catalog.reload(); + const uri = 'skill://git-workflow/LICENSE'; + const license = firstContent(await resultOf(mcp, 'resources/read', { uri })); + expect(license).toEqual({ uri, mimeType: 'application/octet-stream', text: 'MIT License\n' }); + }); + + it('serves names with a space or a non-ASCII letter, however the request spells the URI', async () => { + const { mcp } = await loaded(); + for (const [requested, served, text] of [ + [PURCHASE_ORDER, PURCHASE_ORDER, '# PO\n'], + ['skill://acme/billing/refunds/templates/Purchase Order.md', PURCHASE_ORDER, '# PO\n'], + [REMBOURSE, REMBOURSE, '# Remboursement\n'], + ['skill://acme/billing/refunds/templates/Remboursé.md', REMBOURSE, '# Remboursement\n'] + ] as const) { + expect(await resultOf(mcp, 'resources/read', { uri: requested })).toMatchObject({ contents: [{ uri: served, text }] }); + } + }); + + it('answers -32602 when resources/read targets a directory', async () => { + const { mcp } = await loaded(); + expect(await errorOf(mcp, 'resources/read', { uri: 'skill://acme/billing/refunds' })).toMatchObject({ + code: -32_602, + message: expect.stringMatching(/is a directory/) + }); + }); + + it('fails a read whose file changed since loading, warning every time', async () => { + const { mcp, files, warnings } = await loaded(); + files['git-workflow/references/REVIEW.md'] = '# Changed\n'; + const uri = 'skill://git-workflow/references/REVIEW.md'; + expect(await errorOf(mcp, 'resources/read', { uri })).toMatchObject({ code: -32_603 }); + expect(await errorOf(mcp, 'resources/read', { uri })).toMatchObject({ code: -32_603 }); + expect(warnings.filter(warning => warning.message.includes('changed since'))).toHaveLength(2); + }); + + it('fails a read whose content changed at the same size, by its digest', async () => { + const { mcp, files, warnings } = await loaded(); + files['git-workflow/references/REVIEW.md'] = '# Reviex\n'; + expect(await errorOf(mcp, 'resources/read', { uri: 'skill://git-workflow/references/REVIEW.md' })).toMatchObject({ + code: -32_603 + }); + expect(warnings).toContainEqual({ path: 'git-workflow/references/REVIEW.md', message: expect.stringContaining('changed since') }); + }); + + it('reads skill directories, with caching fields on 2026-07-28 only', async () => { + const { mcp } = await loaded(); + const uri = 'skill://acme/billing/refunds'; + expect(await resultOf(mcp, 'resources/directory/read', { uri })).toMatchObject({ + resources: [{ name: 'SKILL.md' }, { name: 'assets' }, { name: 'escalation' }, { name: 'templates' }], + ttlMs: 0, + cacheScope: 'private' + }); + expect(await resultOf(mcp, 'resources/directory/read', { uri }, LEGACY)).not.toHaveProperty('ttlMs'); + expect(await errorOf(mcp, 'resources/directory/read', { uri: 'skill://acme' })).toMatchObject({ code: -32_602 }); + }); + + it('omits caching fields on skills/list for 2025-era connections', async () => { + const { mcp } = await loaded(); + expect(await resultOf(mcp, 'skills/list', {}, LEGACY)).not.toHaveProperty('ttlMs'); + }); + + it('serves many servers from one catalog', async () => { + const { catalog } = await loaded(); + const servers = [newServer(), newServer(), newServer()]; + for (const server of servers) catalog.attach(server); + for (const server of servers) expect(await resultOf(server, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); + }); + + it('keeps attach cheap while many attached servers are still alive', async () => { + const { catalog } = await loaded(); + const servers = Array.from({ length: 1100 }, () => newServer()); + const deref = WeakRef.prototype.deref; + let derefs = 0; + WeakRef.prototype.deref = function (this: WeakRef) { + derefs++; + return deref.call(this); + }; + try { + for (const server of servers) catalog.attach(server); + } finally { + WeakRef.prototype.deref = deref; + } + expect(derefs).toBeLessThan(2 * servers.length); + }); + + it('hands out frozen entries: an edit throws and never changes what is served', async () => { + const { catalog, mcp } = await loaded(); + const refunds = catalog.entries.find(skill => skill.uri === REFUNDS)!; + expect(() => { + (refunds.frontmatter as Record)['description'] = 'edited for display'; + }).toThrow(TypeError); + expect(() => { + (refunds.resources as SkillResourceEntry[])[0]!.digest = `sha256:${'0'.repeat(64)}`; + }).toThrow(TypeError); + expect(() => (refunds.resources as SkillResourceEntry[]).pop()).toThrow(TypeError); + expect(() => (catalog.entries as Skill[]).pop()).toThrow(TypeError); + expect(await resultOf(mcp, 'skills/get', { uri: REFUNDS })).toMatchObject({ + skill: { frontmatter: { description: 'Refund workflow.' } } + }); + expect(catalog.entries).toHaveLength(3); + }); + + it('freezes nested frontmatter too', async () => { + const skillMd = `--- +name: owned +description: d +metadata: + owner: billing +tags: [a, b] +--- +`; + const catalog = await SkillsExtension.load(memorySkillFiles({ 'owned/SKILL.md': skillMd })); + const frontmatter = catalog.entries[0]!.frontmatter as unknown as { metadata: Record; tags: string[] }; + expect(() => { + frontmatter.metadata['owner'] = 'someone else'; + }).toThrow(TypeError); + expect(() => frontmatter.tags.push('c')).toThrow(TypeError); + }); +}); + +describe('SkillCatalog.reload', () => { + it('returns what changed', async () => { + const { catalog, files } = await loaded(); + delete files['git-workflow/SKILL.md']; + delete files['git-workflow/references/REVIEW.md']; + files['acme/billing/refunds/templates/Purchase Order.md'] = '# PO v2\n'; + expect(await catalog.reload()).toEqual({ added: [], removed: [GIT], changed: [REFUNDS], listChanged: true }); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION]); + }); + + it('reports listChanged only when what clients list changes', async () => { + const { catalog, files } = await loaded(); + expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [], listChanged: false }); + files['git-workflow/references/REVIEW.md'] = '# Review v2\n'; + expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [GIT], listChanged: false }); + files['git-workflow/SKILL.md'] = GIT_SKILL_MD.replace('Branching and review conventions.', 'Branching, review and release.'); + expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [GIT], listChanged: true }); + files['aaa/SKILL.md'] = NEW_SKILL; + expect(await catalog.reload()).toMatchObject({ added: ['skill://aaa/SKILL.md'], listChanged: true }); + }); + + it('attaches and reloads where the runtime has no WeakRef, tracking no servers (Workers before 2025-05-05)', async () => { + const { catalog, files } = await loaded(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'WeakRef')!; + Reflect.deleteProperty(globalThis, 'WeakRef'); + try { + expect(typeof WeakRef).toBe('undefined'); + const mcp = newServer(); + expect(() => catalog.attach(mcp)).not.toThrow(); + expect(await resultOf(mcp, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); + const notify = vi.spyOn(mcp.server, 'sendResourceListChanged'); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await mcp.connect(serverSide); + files['aaa/SKILL.md'] = NEW_SKILL; + expect(await catalog.reload()).toMatchObject({ added: ['skill://aaa/SKILL.md'], listChanged: true }); + expect(notify).not.toHaveBeenCalled(); + await mcp.close(); + } finally { + Object.defineProperty(globalThis, 'WeakRef', descriptor); + } + }); + + it('keeps serving the old catalog when a reload fails', async () => { + const { catalog, files, mcp } = await loaded({ strict: true }); + files['git-workflow/SKILL.md'] = `--- +name: renamed +description: d +--- +`; + await expect(catalog.reload()).rejects.toBeInstanceOf(SkillCatalogError); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(await resultOf(mcp, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); + }); + + it('runs overlapping reloads one after another', async () => { + const { catalog, files } = await loaded(); + files['aaa/SKILL.md'] = NEW_SKILL; + const [first, second] = await Promise.all([catalog.reload(), catalog.reload()]); + expect(first.added).toEqual(['skill://aaa/SKILL.md']); + expect(second.added).toEqual([]); + }); + + it('notifies each connected server once, and only when the listing changes', async () => { + const { catalog, files, mcp: disconnected } = await loaded(); + const mcp = newServer(); + catalog.attach(mcp); + catalog.attach(mcp); + const notify = vi.spyOn(mcp.server, 'sendResourceListChanged'); + const skipped = vi.spyOn(disconnected.server, 'sendResourceListChanged'); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await mcp.connect(serverSide); + files['git-workflow/references/REVIEW.md'] = '# Review v2\n'; + expect(await catalog.reload()).toMatchObject({ listChanged: false }); + expect(notify).not.toHaveBeenCalled(); + files['aaa/SKILL.md'] = NEW_SKILL; + expect(await catalog.reload()).toMatchObject({ listChanged: true }); + expect(notify).toHaveBeenCalledTimes(1); + expect(skipped).not.toHaveBeenCalled(); + await mcp.close(); + }); + + it('reports a failed list_changed send as a warning, never an unhandled rejection', async () => { + const { catalog, files, warnings } = await loaded(); + const mcp = newServer(); + catalog.attach(mcp); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await mcp.connect(serverSide); + vi.spyOn(serverSide, 'send').mockRejectedValue(new Error('transport down')); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + files['aaa/SKILL.md'] = NEW_SKILL; + await expect(catalog.reload()).resolves.toMatchObject({ added: ['skill://aaa/SKILL.md'] }); + const sendFailed = { path: 'resources/list_changed', message: expect.stringContaining('transport down') }; + await vi.waitFor(() => expect(warnings).toContainEqual(sendFailed)); + await new Promise(resolve => setImmediate(resolve)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + await mcp.close(); + } + }); + + it('keeps a throwing onWarning from escaping a failed send or skipping the other servers', async () => { + const sink = vi.fn(() => { + throw new Error('sink failed'); + }); + const { catalog, files } = await loaded({ onWarning: sink }); + const servers = [newServer(), newServer(), newServer()]; + for (const server of servers) { + catalog.attach(server); + await server.connect(InMemoryTransport.createLinkedPair()[1]); + } + const [throwsSync, rejects, healthy] = servers.map(server => vi.spyOn(server.server, 'sendResourceListChanged')); + throwsSync!.mockImplementation(() => { + throw new Error('send threw'); + }); + rejects!.mockRejectedValue(new Error('transport down')); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + files['aaa/SKILL.md'] = NEW_SKILL; + await expect(catalog.reload()).resolves.toMatchObject({ listChanged: true }); + expect(healthy).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(sink).toHaveBeenCalledTimes(2)); + await new Promise(resolve => setImmediate(resolve)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + for (const server of servers) await server.close(); + } + }); +}); diff --git a/packages/server/test/ext/skills/catalogBuild.test.ts b/packages/server/test/ext/skills/catalogBuild.test.ts new file mode 100644 index 0000000000..3efbe5a54b --- /dev/null +++ b/packages/server/test/ext/skills/catalogBuild.test.ts @@ -0,0 +1,381 @@ +import { createHash } from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; + +import type { CatalogBuildOptions } from '../../../src/ext/skills/catalogBuild'; +import { buildCatalogSnapshot } from '../../../src/ext/skills/catalogBuild'; +import { SkillCatalogError } from '../../../src/ext/skills/errors'; +import { memorySkillFiles } from '../../../src/ext/skills/files'; +import type { SkillFileEntry, SkillFiles, SkillWarning } from '../../../src/ext/skills/types'; +import { ESCALATION, fixtureFiles, GIT, LOGO, PNG, PURCHASE_ORDER, REFUNDS, REMBOURSE } from './fixtures'; + +function optionsWith(overrides: Partial = {}) { + const warnings: SkillWarning[] = []; + const options: CatalogBuildOptions = { + prefix: [], + limits: { files: 512, bytes: 16_777_216 }, + strict: false, + warn: warning => warnings.push(warning), + ...overrides + }; + return { warnings, options }; +} + +/** The problems a strict build fails with, sorted by path; `[]` when it succeeds. Any other error fails the test. */ +async function strictProblems(files: SkillFiles, overrides: Partial = {}): Promise { + try { + await buildCatalogSnapshot(files, optionsWith({ ...overrides, strict: true }).options); + return []; + } catch (error) { + if (error instanceof SkillCatalogError) return error.problems.toSorted(byPath); + throw error; + } +} + +/** `files` plus extra entries in some directories, the way a disk adapter reports symlinks and empty folders. */ +function withEntries(files: SkillFiles, extra: Record): SkillFiles { + return { + async list(dir) { + if (!Object.hasOwn(extra, dir)) return files.list(dir); + const existing = await files.list(dir).catch((): SkillFileEntry[] => []); + return [...existing, ...extra[dir]!]; + }, + read: path => files.read(path) + }; +} + +const byPath = (a: SkillWarning, b: SkillWarning): number => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0); +const names = (entries: readonly SkillFileEntry[]): string[] => entries.map(entry => entry.name).toSorted(); +const text = (bytes: Uint8Array): string => new TextDecoder().decode(bytes); + +const INVALID = `--- +name: other +description: d +--- +`; +const NOT_FOLLOWED = /symlinks and other special files are not followed; copy the file instead/; +const UNREPRESENTABLE = /cannot be represented in a URI/; + +describe('buildCatalogSnapshot', () => { + it('builds flat, URI-sorted entries; a nested skill is also a file of the enclosing skill', async () => { + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith().options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(snapshot.skills.get(REFUNDS)?.resources).toEqual( + expect.arrayContaining([ + { uri: ESCALATION, digest: expect.stringMatching(/^sha256:[\da-f]{64}$/), size: expect.any(Number) }, + { uri: LOGO, digest: `sha256:${createHash('sha256').update(PNG).digest('hex')}`, size: PNG.byteLength } + ]) + ); + }); + + it('skips hidden files and folders', async () => { + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith().options); + const paths = [...snapshot.files.values()].map(file => file.path); + expect(paths).toContain('git-workflow/references/REVIEW.md'); + expect(paths.some(path => path.split('/').some(segment => segment.startsWith('.')))).toBe(false); + }); + + it('lists one SKILL.md resource per skill', async () => { + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith().options); + expect(snapshot.listed).toEqual([ + { uri: REFUNDS, name: 'refunds', description: 'Refund workflow.', mimeType: 'text/markdown', size: expect.any(Number) }, + { uri: ESCALATION, name: 'escalation', description: 'Escalate a refund.', mimeType: 'text/markdown', size: expect.any(Number) }, + { + uri: GIT, + name: 'git-workflow', + description: 'Branching and review conventions.', + mimeType: 'text/markdown', + size: expect.any(Number) + } + ]); + }); + + it('treats skill roots and folders inside skills as directories, but not organisational folders', async () => { + const { dirs } = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith().options); + expect(dirs.has('skill://acme')).toBe(false); + expect(dirs.has('skill://acme/billing')).toBe(false); + expect(dirs.get('skill://acme/billing/refunds')?.map(child => [child.name, child.mimeType])).toEqual([ + ['SKILL.md', 'text/markdown'], + ['assets', 'inode/directory'], + ['escalation', 'inode/directory'], + ['templates', 'inode/directory'] + ]); + expect(dirs.get('skill://acme/billing/refunds/templates')?.map(child => [child.name, child.uri])).toEqual([ + ['Purchase Order.md', PURCHASE_ORDER], + ['Remboursé.md', REMBOURSE] + ]); + }); + + it('publishes reserved characters in names as a host resolves the plain name', async () => { + const names = ['Q&A.md', 'c++.md', '[draft].md', 'me@home.md']; + const files = { + ...fixtureFiles(), + ...Object.fromEntries([...names, '100%.md'].map(name => [`git-workflow/references/${name}`, '# x\n'])) + }; + const snapshot = await buildCatalogSnapshot(memorySkillFiles(files), optionsWith().options); + const resources = snapshot.skills.get(GIT)?.resources; + const uris = resources === undefined || resources === 'dynamic' ? [] : resources.map(resource => resource.uri); + for (const name of names) expect(uris).toContain(new URL(`references/${name}`, 'skill://git-workflow/').href); + // `%` is always encoded: a raw one would read as the start of an escape. + expect(uris).toContain('skill://git-workflow/references/100%25.md'); + }); + + it('puts a prefix in front of every URI', async () => { + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith({ prefix: ['shop'] }).options); + expect(snapshot.ordered.every(skill => skill.uri.startsWith('skill://shop/'))).toBe(true); + }); + + it('encodes a top-level folder strictly, since it is the URI authority', async () => { + const files = { + 'me@home/refunds/SKILL.md': `--- +name: refunds +description: d +--- +`, + 'me@home/refunds/references/Q&A.md': '# Q&A\n', + '[draft]/notes/SKILL.md': `--- +name: notes +description: d +--- +` + }; + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(memorySkillFiles(files), options); + expect(warnings).toEqual([]); + const uris = snapshot.ordered.map(skill => skill.uri); + expect(uris).toEqual(['skill://%5Bdraft%5D/notes/SKILL.md', 'skill://me%40home/refunds/SKILL.md']); + for (const uri of uris) expect(new URL(uri).href).toBe(uri); + const refunds = snapshot.skills.get('skill://me%40home/refunds/SKILL.md')?.resources; + const listed = refunds === undefined || refunds === 'dynamic' ? [] : refunds.map(resource => resource.uri); + expect(listed).toContain(new URL('references/Q&A.md', 'skill://me%40home/refunds/SKILL.md').href); + }); + + it('encodes a prefix strictly, so acme:prod is not read as a host and port', async () => { + const { options, warnings } = optionsWith({ prefix: ['acme:prod'] }); + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), options); + expect(warnings).toEqual([]); + expect(snapshot.ordered).toHaveLength(3); + expect(snapshot.ordered.every(skill => skill.uri.startsWith('skill://acme%3Aprod/') && new URL(skill.uri).href === skill.uri)).toBe( + true + ); + }); + + it('skips invalid skills and warns about each one', async () => { + const files = { + ...fixtureFiles(), + 'bad/SKILL.md': INVALID, + 'broken/SKILL.md': `--- +name: &a broken +description: d +--- +`, + 'SKILL.md': `--- +name: root +description: d +--- +`, + 'notes/skill.md': '# not a skill' + }; + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(memorySkillFiles(files), options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(warnings.map(warning => warning.path).toSorted()).toEqual(['SKILL.md', 'bad/SKILL.md', 'broken/SKILL.md', 'notes/skill.md']); + }); + + it('skips a skill that contains a symlink or other special file', async () => { + const memory = memorySkillFiles(fixtureFiles()); + const files: SkillFiles = { + list: async dir => { + const entries = await memory.list(dir); + return dir === 'git-workflow' ? [...entries, { name: 'run.sh', kind: 'unsupported' }] : entries; + }, + read: path => memory.read(path) + }; + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(files, options); + expect(snapshot.skills.has(GIT)).toBe(false); + expect(warnings).toContainEqual(expect.objectContaining({ path: 'git-workflow/run.sh' })); + }); + + it('skips a skill whose SKILL.md is a symlink, or fails when strict', async () => { + const files = withEntries(memorySkillFiles({ ...fixtureFiles(), 'linked/README.md': '# Linked\n' }), { + linked: [{ name: 'SKILL.md', kind: 'unsupported' }] + }); + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(files, options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + const expected = [{ path: 'linked/SKILL.md', message: expect.stringMatching(NOT_FOLLOWED) }]; + expect(warnings).toEqual(expected); + expect(await strictProblems(files)).toEqual(expected); + }); + + it('reports a symlink outside any skill, such as a linked skill folder, or fails when strict', async () => { + const files = withEntries(memorySkillFiles(fixtureFiles()), { + '': [{ name: 'shared-skill', kind: 'unsupported' }], + 'acme/billing': [{ name: 'policy.md', kind: 'unsupported' }] + }); + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(files, options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + const expected = ['acme/billing/policy.md', 'shared-skill'].map(path => ({ path, message: expect.stringMatching(NOT_FOLLOWED) })); + expect(warnings.toSorted(byPath)).toEqual(expected); + expect(await strictProblems(files)).toEqual(expected); + }); + + it('skips a skill with a name that cannot be represented in a URI, such as a lone surrogate', async () => { + const memory = memorySkillFiles({ + ...fixtureFiles(), + 'bad/SKILL.md': `--- +name: bad +description: d +--- +`, + 'bad/x\uD800.md': 'x' + }); + // An empty folder too: no file below it carries the name, yet the catalog lists the folder. + const files = withEntries(memory, { 'git-workflow': [{ name: 'empty\uDC00', kind: 'directory' }], 'git-workflow/empty\uDC00': [] }); + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(files, options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION]); + const expected = ['bad/x\uD800.md', 'git-workflow/empty\uDC00'].map(path => ({ + path, + message: expect.stringMatching(UNREPRESENTABLE) + })); + expect(warnings.toSorted(byPath)).toEqual(expected); + expect(await strictProblems(files)).toEqual(expected); + }); + + it('collects every problem into one SkillCatalogError when strict', async () => { + const files = { + ...fixtureFiles(), + 'bad/SKILL.md': INVALID, + 'SKILL.md': `--- +name: r +description: d +--- +` + }; + const error = await buildCatalogSnapshot(memorySkillFiles(files), optionsWith({ strict: true }).options).catch( + (error: unknown) => error + ); + expect(error).toBeInstanceOf(SkillCatalogError); + expect((error as SkillCatalogError).problems.map(problem => problem.path).toSorted()).toEqual(['SKILL.md', 'bad/SKILL.md']); + }); + + it('publishes an oversized skill with a warning, or fails when strict', async () => { + const limits = { files: 2, bytes: 16_777_216 }; + const lenient = optionsWith({ limits }); + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), lenient.options); + expect(snapshot.skills.has(REFUNDS)).toBe(true); + expect(lenient.warnings).toContainEqual({ + path: 'acme/billing/refunds', + message: expect.stringMatching(/exceeds the limit.*Set strict: true to fail instead/) + }); + expect(await strictProblems(memorySkillFiles(fixtureFiles()), { limits })).toContainEqual({ + path: 'acme/billing/refunds', + message: expect.not.stringMatching(/strict/) + }); + }); + + it('reads each file once per build, however deeply skills nest', async () => { + const memory = memorySkillFiles(fixtureFiles()); + const reads = new Map(); + const files: SkillFiles = { + list: dir => memory.list(dir), + read: path => { + reads.set(path, (reads.get(path) ?? 0) + 1); + return memory.read(path); + } + }; + const snapshot = await buildCatalogSnapshot(files, optionsWith().options); + expect([...reads.keys()].toSorted()).toEqual([...snapshot.files.values()].map(file => file.path).toSorted()); + expect(new Set(reads.values())).toEqual(new Set([1])); + }); + + it('always fails on storage errors', async () => { + const { options } = optionsWith(); + const unlistable: SkillFiles = { list: () => Promise.reject(new Error('EACCES')), read: () => Promise.reject(new Error('unused')) }; + await expect(buildCatalogSnapshot(unlistable, options)).rejects.toBeInstanceOf(SkillCatalogError); + const memory = memorySkillFiles(fixtureFiles()); + const unreadable: SkillFiles = { list: dir => memory.list(dir), read: () => Promise.reject(new Error('EIO')) }; + await expect(buildCatalogSnapshot(unreadable, options)).rejects.toBeInstanceOf(SkillCatalogError); + }); + + it('warns when no skills are found', async () => { + const { options, warnings } = optionsWith(); + await buildCatalogSnapshot(memorySkillFiles({}), options); + expect(warnings).toEqual([{ path: '.', message: expect.stringMatching(/no skills found/) }]); + }); +}); + +describe('memorySkillFiles', () => { + it('reads the record when first used and rejects unknown paths', async () => { + const record: Record = { 'a/SKILL.md': 'x' }; + const files = memorySkillFiles(record); + record['a/extra.md'] = 'y'; + const entries = await files.list('a'); + expect(entries.map(entry => entry.name).toSorted()).toEqual(['SKILL.md', 'extra.md']); + await expect(files.list('missing')).rejects.toThrow(); + await expect(files.read('a/missing.md')).rejects.toThrow(); + }); + + it('re-reads the record when a walk starts, and serves changed content at once', async () => { + const record: Record = { 'a/SKILL.md': 'x' }; + const files = memorySkillFiles(record); + expect(names(await files.list(''))).toEqual(['a']); + record['a/b/c.md'] = 'c'; + delete record['a/SKILL.md']; + expect(names(await files.list(''))).toEqual(['a']); + expect(names(await files.list('a'))).toEqual(['b']); + await expect(files.read('a/SKILL.md')).rejects.toThrow(); + record['a/b/c.md'] = 'changed'; + expect(text(await files.read('a/b/c.md'))).toBe('changed'); + }); + + it('shows edits made between two catalog builds', async () => { + const record = fixtureFiles(); + const files = memorySkillFiles(record); + const before = await buildCatalogSnapshot(files, optionsWith().options); + expect(before.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + delete record['git-workflow/SKILL.md']; + record['deploy/SKILL.md'] = `--- +name: deploy +description: Deploy a release. +--- +`; + record['acme/billing/refunds/SKILL.md'] = `--- +name: refunds +description: Refund workflow, revised. +--- +`; + const after = await buildCatalogSnapshot(files, optionsWith().options); + expect(after.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, 'skill://deploy/SKILL.md']); + expect(after.skills.get(REFUNDS)?.frontmatter.description).toBe('Refund workflow, revised.'); + }); + + it('finds files stored under keys that are not normalised', async () => { + const files = memorySkillFiles({ './a/SKILL.md': 'x', 'a//b/c.md': 'c' }); + expect(names(await files.list(''))).toEqual(['a']); + expect(names(await files.list('a'))).toEqual(['SKILL.md', 'b']); + expect(text(await files.read('a/b/c.md'))).toBe('c'); + }); + + it('enumerates the record once per walk, however many directories and files the walk visits', async () => { + let enumerations = 0; + // A key that is not normalised is read through the index, not by re-reading the record. + const target = { ...fixtureFiles(), './git-workflow/references/STYLE.md': '# Style\n' }; + const record = new Proxy(target, { + ownKeys(object) { + enumerations++; + return Reflect.ownKeys(object); + } + }); + const files = memorySkillFiles(record); + const snapshot = await buildCatalogSnapshot(files, optionsWith().options); + expect(snapshot.files.has('skill://git-workflow/references/STYLE.md')).toBe(true); + expect(snapshot.dirs.size).toBeGreaterThan(3); + expect(enumerations).toBe(1); + await buildCatalogSnapshot(files, optionsWith().options); + expect(enumerations).toBe(2); + }); +}); diff --git a/packages/server/test/ext/skills/extension.test.ts b/packages/server/test/ext/skills/extension.test.ts new file mode 100644 index 0000000000..33b06c1d81 --- /dev/null +++ b/packages/server/test/ext/skills/extension.test.ts @@ -0,0 +1,335 @@ +import { createHash } from 'node:crypto'; + +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { ListSkillsRequestParamsSchema, ReadResourceDirectoryRequestParamsSchema } from '@modelcontextprotocol/core/ext/skills'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import type { SkillSource, SkillWarning } from '../../../src/ext/skills/index'; +import { memorySkillFiles, SkillCatalogError, skillEntry, SkillsExtension } from '../../../src/ext/skills/index'; +import { ResourceTemplate } from '../../../src/server/mcp'; +import { GIT, GIT_SKILL_MD } from './fixtures'; +import { errorOf, LEGACY, newServer, resultOf } from './harness'; + +const digest = `sha256:${'a'.repeat(64)}`; +const gitWorkflow: Skill = { + uri: 'skill://git-workflow/SKILL.md', + frontmatter: { name: 'git-workflow', description: 'Branching and review conventions.' }, + resources: [{ uri: 'skill://git-workflow/SKILL.md', digest, size: 5 }] +}; + +function sourceOf(overrides: Partial = {}): SkillSource { + return { + list: async () => ({ skills: [gitWorkflow] }), + get: async uri => (uri === gitWorkflow.uri ? gitWorkflow : undefined), + read: async uri => (uri === gitWorkflow.uri ? { text: '# Git', mimeType: 'text/markdown' } : undefined), + ...overrides + }; +} + +function attached(options: Partial[0]> = {}) { + const warnings: SkillWarning[] = []; + const mcp = newServer(); + new SkillsExtension({ source: sourceOf(), onWarning: warning => warnings.push(warning), ...options }).attach(mcp); + return { mcp, warnings }; +} + +describe('capability', () => { + it('declares the extension, with directoryRead only when the source reads directories', () => { + expect(attached().mcp.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': {} }); + const withDirectories = attached({ source: sourceOf({ readDirectory: async () => ({ resources: [] }) }) }); + expect(withDirectories.mcp.server.getCapabilities().extensions).toEqual({ + 'io.modelcontextprotocol/skills': { directoryRead: true } + }); + expect(withDirectories.mcp.server.getCapabilities().resources).toBeDefined(); + }); +}); + +describe('skills/list', () => { + it('serves the page with caching fields on 2026-07-28 only', async () => { + const { mcp } = attached({ cacheHint: { ttlMs: 60_000, cacheScope: 'public' } }); + expect(await resultOf(mcp, 'skills/list')).toMatchObject({ skills: [gitWorkflow], ttlMs: 60_000, cacheScope: 'public' }); + const legacy = await resultOf(mcp, 'skills/list', {}, LEGACY); + expect(legacy['skills']).toEqual([gitWorkflow]); + expect(legacy).not.toHaveProperty('ttlMs'); + expect(legacy).not.toHaveProperty('cacheScope'); + }); + + it('defaults the caching fields to 0 / private', async () => { + expect(await resultOf(attached().mcp, 'skills/list')).toMatchObject({ ttlMs: 0, cacheScope: 'private' }); + }); + + it('passes nextCursor through', async () => { + const { mcp } = attached({ source: sourceOf({ list: async () => ({ skills: [gitWorkflow], nextCursor: 'n' }) }) }); + expect(await resultOf(mcp, 'skills/list')).toMatchObject({ nextCursor: 'n' }); + }); + + it('drops invalid entries from a source and warns, or fails with -32603 when strict', async () => { + const broken = { ...gitWorkflow, uri: 'skill://broken/SKILL.md' }; + const lenient = attached({ source: sourceOf({ list: async () => ({ skills: [gitWorkflow, broken] }) }) }); + const { skills } = await resultOf(lenient.mcp, 'skills/list'); + expect(skills).toEqual([gitWorkflow]); + expect(lenient.warnings[0]).toMatchObject({ path: 'skill://broken/SKILL.md' }); + const strict = attached({ strict: true, source: sourceOf({ list: async () => ({ skills: [broken] }) }) }); + expect(await errorOf(strict.mcp, 'skills/list')).toMatchObject({ code: -32_603 }); + }); +}); + +describe('skills/get', () => { + it('returns the entry with caching fields', async () => { + expect(await resultOf(attached().mcp, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ skill: gitWorkflow, ttlMs: 0 }); + }); + + it('answers -32602 for a skill it does not serve', async () => { + expect(await errorOf(attached().mcp, 'skills/get', { uri: 'skill://nope/SKILL.md' })).toMatchObject({ code: -32_602 }); + }); + + it('answers -32603 when the source returns a different or invalid skill', async () => { + const other = { + ...gitWorkflow, + uri: 'skill://other/SKILL.md', + frontmatter: { name: 'other', description: 'd' }, + resources: [{ uri: 'skill://other/SKILL.md', digest, size: 1 }] + }; + const differs = attached({ source: sourceOf({ get: async () => other }) }); + expect(await errorOf(differs.mcp, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ code: -32_603 }); + const invalid = { ...gitWorkflow, frontmatter: { name: 'Wrong', description: 'd' } }; + const broken = attached({ source: sourceOf({ get: async () => invalid }) }); + expect(await errorOf(broken.mcp, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ code: -32_603 }); + }); +}); + +describe('resources/read through the skill template', () => { + it('serves text, serves bytes as base64, and answers -32602 for unknown files', async () => { + const bytes = Uint8Array.from([0, 1, 2, 255]); + const { mcp } = attached({ + source: sourceOf({ + read: async uri => { + if (uri === gitWorkflow.uri) return { text: '# Git', mimeType: 'text/markdown' }; + if (uri === 'skill://git-workflow/logo.png') return { bytes, mimeType: 'image/png' }; + return; + } + }) + }); + const { contents: text } = await resultOf(mcp, 'resources/read', { uri: gitWorkflow.uri }); + expect(text).toEqual([{ uri: gitWorkflow.uri, mimeType: 'text/markdown', text: '# Git' }]); + const { contents: binary } = await resultOf(mcp, 'resources/read', { uri: 'skill://git-workflow/logo.png' }); + expect(binary).toEqual([ + { uri: 'skill://git-workflow/logo.png', mimeType: 'image/png', blob: Buffer.from(bytes).toString('base64') } + ]); + expect(await errorOf(mcp, 'resources/read', { uri: 'skill://git-workflow/missing.md' })).toMatchObject({ code: -32_602 }); + }); + + it('registers the skill:// template under the extension id', async () => { + const { resourceTemplates } = await resultOf(attached().mcp, 'resources/templates/list'); + expect(resourceTemplates).toEqual([ + expect.objectContaining({ name: 'io.modelcontextprotocol/skills', uriTemplate: 'skill://{+path}' }) + ]); + }); +}); + +describe('resources/directory/read', () => { + it('passes pages through with caching fields, answers -32602 for non-directories, and is absent without readDirectory', async () => { + const child = { uri: 'skill://git-workflow/SKILL.md', name: 'SKILL.md', mimeType: 'text/markdown' }; + const { mcp } = attached({ + source: sourceOf({ + readDirectory: async uri => (uri === 'skill://git-workflow' ? { resources: [child], nextCursor: 'n' } : undefined) + }) + }); + expect(await resultOf(mcp, 'resources/directory/read', { uri: 'skill://git-workflow' })).toMatchObject({ + resources: [child], + nextCursor: 'n', + ttlMs: 0, + cacheScope: 'private' + }); + expect(await errorOf(mcp, 'resources/directory/read', { uri: 'skill://git-workflow/SKILL.md' })).toMatchObject({ code: -32_602 }); + expect(await errorOf(attached().mcp, 'resources/directory/read', { uri: 'skill://git-workflow' })).toMatchObject({ code: -32_601 }); + }); +}); + +describe('resources/list', () => { + it('lists nothing from a source by default and what listResources returns otherwise', async () => { + const { resources: none } = await resultOf(attached().mcp, 'resources/list'); + expect(none).toEqual([]); + const listed = { uri: gitWorkflow.uri, name: 'git-workflow', description: 'd', mimeType: 'text/markdown' }; + const outside = { uri: 'other://x/SKILL.md', name: 'x' }; + const { mcp, warnings } = attached({ source: sourceOf({ listResources: async () => [listed, outside] }) }); + const { resources } = await resultOf(mcp, 'resources/list'); + expect(resources).toEqual([listed]); + expect(warnings[0]).toMatchObject({ path: 'other://x/SKILL.md' }); + }); +}); + +describe('baseUri', () => { + it('serves a source under another scheme', async () => { + const github: Skill = { + uri: 'github://owner/repo/skills/refunds/SKILL.md', + frontmatter: { name: 'refunds', description: 'Refunds.' }, + resources: [{ uri: 'github://owner/repo/skills/refunds/SKILL.md', digest, size: 1 }] + }; + const { mcp } = attached({ + baseUri: 'github://owner/repo/skills', + source: sourceOf({ + list: async () => ({ skills: [github] }), + read: async uri => (uri === github.uri ? { text: 'x' } : undefined) + }) + }); + const { resourceTemplates } = await resultOf(mcp, 'resources/templates/list'); + expect(resourceTemplates).toEqual([expect.objectContaining({ uriTemplate: 'github://owner/repo/skills/{+path}' })]); + const { contents } = await resultOf(mcp, 'resources/read', { uri: github.uri }); + expect(contents).toEqual([{ uri: github.uri, text: 'x' }]); + }); + + it('matches requests however the baseUri spells its host and port', async () => { + const uri = 'https://example.com/skills/refunds/SKILL.md'; + const listed = { uri, name: 'refunds' }; + const { mcp, warnings } = attached({ + baseUri: 'https://Example.com:443/skills/', + source: sourceOf({ + read: async requested => (requested === uri ? { text: 'x' } : undefined), + listResources: async () => [listed] + }) + }); + expect(await resultOf(mcp, 'resources/templates/list')).toMatchObject({ + resourceTemplates: [{ uriTemplate: 'https://example.com/skills/{+path}' }] + }); + expect(await resultOf(mcp, 'resources/read', { uri: 'https://EXAMPLE.com:443/skills/refunds/SKILL.md' })).toMatchObject({ + contents: [{ uri, text: 'x' }] + }); + expect(await resultOf(mcp, 'resources/list')).toMatchObject({ resources: [listed] }); + expect(warnings).toEqual([]); + }); + + it('rejects a baseUri without a scheme, or one that does not parse', () => { + expect(() => new SkillsExtension({ source: sourceOf(), baseUri: 'skills' })).toThrow(TypeError); + expect(() => new SkillsExtension({ source: sourceOf(), baseUri: 'https://exa mple.com' })).toThrow(TypeError); + }); +}); + +describe('attach', () => { + it('is a no-op the second time on the same server and serves many servers', async () => { + const extension = new SkillsExtension({ source: sourceOf() }); + const first = newServer(); + const second = newServer(); + extension.attach(first); + extension.attach(first); + extension.attach(second); + expect(await resultOf(first, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ skill: gitWorkflow }); + expect(await resultOf(second, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ skill: gitWorkflow }); + expect(first.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': {} }); + }); + + it('refuses a second skills extension on a server, leaving the first one serving with its capability unchanged', async () => { + const mcp = newServer(); + const catalog = await SkillsExtension.load(memorySkillFiles({ 'git-workflow/SKILL.md': GIT_SKILL_MD })); + catalog.attach(mcp); + const declared = { 'io.modelcontextprotocol/skills': { directoryRead: true } }; + expect(mcp.server.getCapabilities().extensions).toEqual(declared); + // A source without readDirectory would declare `{}`: the probe that used to overwrite `directoryRead`. + const second = new SkillsExtension({ source: sourceOf() }); + expect(() => second.attach(mcp)).toThrow(/another skills extension is already attached/); + expect(() => second.attach(mcp)).toThrow(/another skills extension is already attached/); + expect(mcp.server.getCapabilities().extensions).toEqual(declared); + expect(await resultOf(mcp, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); + expect(await resultOf(mcp, 'resources/directory/read', { uri: 'skill://git-workflow' })).toMatchObject({ + resources: [{ name: 'SKILL.md' }] + }); + // The refused extension still attaches to a server of its own. + const own = newServer(); + second.attach(own); + expect(own.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': {} }); + }); + + it('refuses to replace a handler the server already has for a skills method, changing nothing', async () => { + const mcp = newServer(); + mcp.server.setRequestHandler('skills/list', { params: ListSkillsRequestParamsSchema }, () => ({ skills: [] })); + const extension = new SkillsExtension({ source: sourceOf() }); + expect(() => extension.attach(mcp)).toThrow('A request handler for skills/list already exists, which would be overridden'); + expect(mcp.server.getCapabilities().extensions).toBeUndefined(); + expect(await resultOf(mcp, 'skills/list')).toMatchObject({ skills: [] }); + mcp.server.removeRequestHandler('skills/list'); + extension.attach(mcp); + expect(await resultOf(mcp, 'skills/list')).toMatchObject({ skills: [gitWorkflow] }); + }); + + it('refuses to replace a resources/directory/read handler when the source reads directories', () => { + const mcp = newServer(); + mcp.server.setRequestHandler('resources/directory/read', { params: ReadResourceDirectoryRequestParamsSchema }, () => ({ + resources: [] + })); + const withDirectories = new SkillsExtension({ source: sourceOf({ readDirectory: async () => ({ resources: [] }) }) }); + expect(() => withDirectories.attach(mcp)).toThrow(/resources\/directory\/read already exists/); + expect(mcp.server.getCapabilities().extensions).toBeUndefined(); + new SkillsExtension({ source: sourceOf() }).attach(mcp); + expect(mcp.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': {} }); + }); + + it('declares nothing when the template name is already taken', () => { + const mcp = newServer(); + mcp.registerResource( + 'io.modelcontextprotocol/skills', + new ResourceTemplate('app://{+path}', { list: undefined }), + {}, + async uri => ({ + contents: [{ uri: uri.href, text: 'app' }] + }) + ); + expect(() => new SkillsExtension({ source: sourceOf() }).attach(mcp)).toThrow(/already registered/); + expect(mcp.server.getCapabilities().extensions?.['io.modelcontextprotocol/skills']).toBeUndefined(); + }); + + it('has no attach hook a subclass can override', () => { + let calls = 0; + class Hooked extends SkillsExtension { + afterAttach(): void { + calls++; + } + } + new Hooked({ source: sourceOf() }).attach(newServer()); + expect(calls).toBe(0); + expect('afterAttach' in SkillsExtension.prototype).toBe(false); + }); + + it('throws after the server connected', async () => { + const mcp = newServer(); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await mcp.connect(serverSide); + expect(() => new SkillsExtension({ source: sourceOf() }).attach(mcp)).toThrow(/before connecting/); + await mcp.close(); + }); + + it('rejects an invalid cacheHint at construction', () => { + expect(() => new SkillsExtension({ source: sourceOf(), cacheHint: { ttlMs: -1 } })).toThrow(); + }); +}); + +describe('skillEntry', () => { + it('builds a valid entry from files with real digests', async () => { + const skillMd = `--- +name: refunds +description: Refund workflow. +--- +# Refunds +`; + const skill = await skillEntry('acme/refunds', { 'SKILL.md': skillMd, 'references/policy.md': '# Policy\n' }); + expect(skill.uri).toBe('skill://acme/refunds/SKILL.md'); + expect(skill.resources).toContainEqual({ + uri: 'skill://acme/refunds/SKILL.md', + digest: `sha256:${createHash('sha256').update(skillMd).digest('hex')}`, + size: Buffer.byteLength(skillMd) + }); + }); + + it('throws SkillCatalogError for an invalid skill and TypeError for an escaping path', async () => { + await expect( + skillEntry('acme/refunds', { + 'SKILL.md': `--- +name: other +description: d +--- +` + }) + ).rejects.toBeInstanceOf(SkillCatalogError); + await expect(skillEntry('acme/../x', { 'SKILL.md': '' })).rejects.toBeInstanceOf(TypeError); + }); +}); diff --git a/packages/server/test/ext/skills/fixtures.ts b/packages/server/test/ext/skills/fixtures.ts new file mode 100644 index 0000000000..3c670fdb8d --- /dev/null +++ b/packages/server/test/ext/skills/fixtures.ts @@ -0,0 +1,43 @@ +export const GIT_SKILL_MD = `--- +name: git-workflow +description: Branching and review conventions. +--- +# Git workflow + +See references/REVIEW.md. +`; +/** Windows-authored: UTF-8 BOM and CRLF line endings. */ +export const REFUNDS_SKILL_MD = '---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\n# Refunds\r\n'; +export const ESCALATION_SKILL_MD = `--- +name: escalation +description: Escalate a refund. +--- +# Escalation +`; +export const PNG = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]); + +export const GIT = 'skill://git-workflow/SKILL.md'; +export const REFUNDS = 'skill://acme/billing/refunds/SKILL.md'; +export const ESCALATION = 'skill://acme/billing/refunds/escalation/SKILL.md'; +export const LOGO = 'skill://acme/billing/refunds/assets/logo.png'; +export const PURCHASE_ORDER = 'skill://acme/billing/refunds/templates/Purchase%20Order.md'; +export const REMBOURSE = 'skill://acme/billing/refunds/templates/Rembours%C3%A9.md'; + +/** + * A fresh, mutable file map: two top-level skills, a nested skill under an + * organisational prefix, file names with a space and a non-ASCII letter, a + * binary file and hidden files. + */ +export function fixtureFiles(): Record { + return { + 'git-workflow/SKILL.md': GIT_SKILL_MD, + 'git-workflow/references/REVIEW.md': '# Review\n', + 'git-workflow/.private-notes.md': 'hidden', + 'acme/billing/refunds/SKILL.md': REFUNDS_SKILL_MD, + 'acme/billing/refunds/templates/Purchase Order.md': '# PO\n', + 'acme/billing/refunds/templates/Remboursé.md': '# Remboursement\n', + 'acme/billing/refunds/assets/logo.png': PNG, + 'acme/billing/refunds/escalation/SKILL.md': ESCALATION_SKILL_MD, + '.DS_Store': 'junk' + }; +} diff --git a/packages/server/test/ext/skills/harness.ts b/packages/server/test/ext/skills/harness.ts new file mode 100644 index 0000000000..12009433ce --- /dev/null +++ b/packages/server/test/ext/skills/harness.ts @@ -0,0 +1,75 @@ +import type { JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core-internal'; + +import { invoke } from '../../../src/server/invoke'; +import { McpServer } from '../../../src/server/mcp'; + +export const MODERN = '2026-07-28'; +export const LEGACY = '2025-11-25'; + +export interface Reply { + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +let nextId = 1; + +export function newServer(): McpServer { + return new McpServer({ name: 'skills-test', version: '1.0.0' }); +} + +/** Sends one JSON-RPC request through the real dispatch path, on the given protocol revision. */ +export async function call( + mcp: McpServer, + method: string, + params: Record = {}, + revision: string = MODERN +): Promise { + const modern = revision === MODERN; + const classification: MessageClassification = modern ? { era: 'modern', revision } : { era: 'legacy', revision }; + const meta = modern + ? { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'skills-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + : undefined; + const message = { + jsonrpc: '2.0', + id: nextId++, + method, + params: meta === undefined ? params : { ...params, _meta: meta } + } as JSONRPCRequest; + setNegotiatedProtocolVersion(mcp.server, revision); + const response = await invoke(mcp, message, { classification }); + return (await response.json()) as Reply; +} + +/** The result of a request that must succeed. */ +export async function resultOf( + mcp: McpServer, + method: string, + params: Record = {}, + revision: string = MODERN +): Promise> { + const reply = await call(mcp, method, params, revision); + if (reply.result === undefined) throw new Error(`${method} failed: ${JSON.stringify(reply.error)}`); + return reply.result; +} + +/** The error of a request that must fail. */ +export async function errorOf( + mcp: McpServer, + method: string, + params: Record = {}, + revision: string = MODERN +): Promise> { + const reply = await call(mcp, method, params, revision); + if (reply.error === undefined) throw new Error(`${method} unexpectedly succeeded`); + return reply.error; +} diff --git a/packages/server/test/ext/skills/node.test.ts b/packages/server/test/ext/skills/node.test.ts new file mode 100644 index 0000000000..427b245a89 --- /dev/null +++ b/packages/server/test/ext/skills/node.test.ts @@ -0,0 +1,141 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { fsPathWithin } from '../../../src/ext/skills/fsPath'; +import type { SkillWarning } from '../../../src/ext/skills/index'; +import { memorySkillFiles, SkillCatalogError, SkillsExtension } from '../../../src/ext/skills/index'; +import { loadSkills, nodeSkillFiles } from '../../../src/ext/skills/node'; +import { ESCALATION, fixtureFiles, GIT, PNG, REFUNDS } from './fixtures'; +import { errorOf, newServer, resultOf } from './harness'; + +let root: string; + +async function writeTree(files: Record): Promise { + for (const [relative, content] of Object.entries(files)) { + const target = path.join(root, ...relative.split('/')); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + } +} + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'mcp-skills-')); + await writeTree(fixtureFiles()); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe('nodeSkillFiles', () => { + it('lists files and folders and reads exact bytes', async () => { + const files = nodeSkillFiles(root); + const entries = await files.list('acme/billing/refunds'); + expect(entries.map(entry => [entry.name, entry.kind]).toSorted()).toEqual([ + ['SKILL.md', 'file'], + ['assets', 'directory'], + ['escalation', 'directory'], + ['templates', 'directory'] + ]); + expect(await files.read('acme/billing/refunds/assets/logo.png')).toEqual(PNG); + }); + + it.skipIf(process.platform === 'win32')('reports symlinks as unsupported and never reads through them', async () => { + await symlink(path.join(root, 'git-workflow', 'references', 'REVIEW.md'), path.join(root, 'git-workflow', 'linked.md')); + const files = nodeSkillFiles(root); + expect(await files.list('git-workflow')).toContainEqual({ name: 'linked.md', kind: 'unsupported' }); + await expect(files.read('git-workflow/linked.md')).rejects.toThrow(); + }); + + it('rejects paths that leave the root', async () => { + await expect(nodeSkillFiles(root).read('../outside.md')).rejects.toThrow(TypeError); + await expect(nodeSkillFiles(root).list('acme/../..')).rejects.toThrow(TypeError); + }); +}); + +// The containment rule nodeSkillFiles applies, run with each platform's path rules so every OS tests both. +describe('fsPathWithin', () => { + it('rejects a Windows ..\\ escape by Windows rules', () => { + const base = String.raw`C:\skills`; + // What the adapter used to do: `path.join` resolves the backslash `..` and leaves the root. + expect(path.win32.join(base, String.raw`..\secret`)).toBe(String.raw`C:\secret`); + for (const escape of [String.raw`..\secret`, String.raw`git-workflow\..\..\secret`, String.raw`git-workflow/..\..\secret`]) { + expect(() => fsPathWithin(path.win32, base, escape)).toThrow(TypeError); + } + expect(() => fsPathWithin(path.win32, base, String.raw`git-workflow\..\SKILL.md`)).toThrow(TypeError); + expect(() => fsPathWithin(path.win32, base, '../secret')).toThrow(TypeError); + }); + + it('keeps paths inside the root by Windows rules', () => { + const base = String.raw`C:\skills`; + expect(fsPathWithin(path.win32, base, '')).toBe(base); + expect(fsPathWithin(path.win32, base, 'git-workflow/references/REVIEW.md')).toBe( + String.raw`C:\skills\git-workflow\references\REVIEW.md` + ); + expect(fsPathWithin(path.win32, base, '..notes/a.md')).toBe(String.raw`C:\skills\..notes\a.md`); + }); + + it('treats a backslash as part of a name by POSIX rules', () => { + expect(fsPathWithin(path.posix, '/skills', String.raw`..\secret`)).toBe(String.raw`/skills/..\secret`); + expect(fsPathWithin(path.posix, '/skills', 'git-workflow/SKILL.md')).toBe('/skills/git-workflow/SKILL.md'); + expect(() => fsPathWithin(path.posix, '/skills', '../secret')).toThrow(TypeError); + expect(() => fsPathWithin(path.posix, '/skills', 'a/../../secret')).toThrow(TypeError); + }); +}); + +describe('loadSkills', () => { + it('builds the same entries as the in-memory adapter', async () => { + const fromDisk = await loadSkills(root); + const inMemory = await SkillsExtension.load(memorySkillFiles(fixtureFiles())); + expect(fromDisk.entries).toEqual(inMemory.entries); + expect(fromDisk.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + }); + + it.skipIf(process.platform === 'win32')('skips a skill that contains a symlink, with a warning', async () => { + await symlink(path.join(root, 'git-workflow', 'references', 'REVIEW.md'), path.join(root, 'git-workflow', 'linked.md')); + const warnings: SkillWarning[] = []; + const catalog = await loadSkills(root, { onWarning: warning => warnings.push(warning) }); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION]); + expect(warnings).toContainEqual({ path: 'git-workflow/linked.md', message: expect.stringMatching(/symlinks/) }); + }); + + it.skipIf(process.platform === 'win32')('skips a symlinked skill folder at the root, with a warning, and loads the rest', async () => { + const outside = await mkdtemp(path.join(tmpdir(), 'mcp-skills-linked-')); + try { + await writeFile( + path.join(outside, 'SKILL.md'), + `--- +name: linked-skill +description: Lives outside the root. +--- +# Linked +` + ); + await symlink(outside, path.join(root, 'linked-skill'), 'dir'); + const warnings: SkillWarning[] = []; + const catalog = await loadSkills(root, { onWarning: warning => warnings.push(warning) }); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(warnings).toContainEqual({ path: 'linked-skill', message: expect.stringMatching(/symlinks.*\(skipped\)/) }); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it('fails with SkillCatalogError when the folder does not exist', async () => { + await expect(loadSkills(path.join(root, 'missing'))).rejects.toBeInstanceOf(SkillCatalogError); + }); + + it('refuses a file edited on disk until reload() publishes it', async () => { + const catalog = await loadSkills(root, { onWarning: () => undefined }); + const mcp = newServer(); + catalog.attach(mcp); + await writeFile(path.join(root, 'git-workflow', 'references', 'REVIEW.md'), '# Review v2\n'); + const uri = 'skill://git-workflow/references/REVIEW.md'; + expect(await errorOf(mcp, 'resources/read', { uri })).toMatchObject({ code: -32_603 }); + expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [GIT], listChanged: false }); + expect(await resultOf(mcp, 'resources/read', { uri })).toMatchObject({ contents: [{ uri, text: '# Review v2\n' }] }); + }); +}); diff --git a/packages/server/test/ext/skills/paging.test.ts b/packages/server/test/ext/skills/paging.test.ts new file mode 100644 index 0000000000..7a51f6d5ea --- /dev/null +++ b/packages/server/test/ext/skills/paging.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeCursor, encodeCursor, paginate, resolvePageSize } from '../../../src/ext/skills/paging'; + +const items = ['skill://a', 'skill://b', 'skill://c']; +const identity = (item: string): string => item; + +describe('paging', () => { + it('round-trips cursors and rejects anything else', () => { + expect(decodeCursor(encodeCursor('skill://b/SKILL.md'))).toBe('skill://b/SKILL.md'); + expect(decodeCursor('not a cursor!')).toBeUndefined(); + expect(decodeCursor('garbage')).toBeUndefined(); + }); + + it('pages by position after the last URI, so inserts elsewhere do not shift the boundary', () => { + const first = paginate(items, undefined, 2, identity); + expect(first.items).toEqual(['skill://a', 'skill://b']); + expect(paginate(['skill://0', ...items], first.nextCursor, 2, identity)).toEqual({ items: ['skill://c'] }); + }); + + it('returns everything with Infinity and throws -32602 for a bad cursor', () => { + expect(paginate(items, undefined, Number.POSITIVE_INFINITY, identity)).toEqual({ items }); + expect(() => paginate(items, 'garbage', 2, identity)).toThrow(/Invalid cursor/); + }); + + it('validates page sizes', () => { + expect(resolvePageSize(undefined)).toBe(100); + expect(resolvePageSize(Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY); + for (const bad of [0, -1, 1.5, Number.NaN]) expect(() => resolvePageSize(bad)).toThrow(RangeError); + }); +}); diff --git a/packages/server/test/server/barrelClean.test.ts b/packages/server/test/server/barrelClean.test.ts index e248f24265..72fc4221bf 100644 --- a/packages/server/test/server/barrelClean.test.ts +++ b/packages/server/test/server/barrelClean.test.ts @@ -14,20 +14,44 @@ const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\ // Anchored at start-of-line so JSDoc-example `from 'ajv'` strings in vendored chunks don't match. const VALIDATOR_BACKEND_IMPORT = /^import[^\n]*?from\s+["'](?:ajv|ajv-formats|@cfworker\/json-schema)["']/m; const ROOT_VALIDATOR_EXPORTS = ['AjvJsonSchemaValidator', 'CfWorkerJsonSchemaValidator', 'CfWorkerSchemaDraft']; +// Static (`from "node:x"`), side-effect (`import "node:x"`, what rolldown keeps for an external whose bindings were +// tree-shaken) and dynamic `node:` imports. Only `.mjs` files are scanned, so `require(` is not matched. +const NODE_BUILTIN_IMPORT = /\b(?:from|import)\s*["']node:|\bimport\s*\(\s*["']node:/; +// The skills extension's shared schemas: a static, side-effect or dynamic import of the external subpath. +const CORE_SKILLS_IMPORT = /\b(?:from|import)\s*\(?\s*["']@modelcontextprotocol\/core\/ext\/skills["']/; +// The skills extension's own code (core-internal's `ext/skills`): the frontmatter parser and the entry builder. +const SKILLS_CODE = /\b(?:FrontmatterError|parseFrontmatterYaml|buildSkillEntry|skillEntryProblems)\b/; -function chunkImportsOf(entryPath: string): string[] { +/** + * An ESM entry and every chunk it imports, following relative `from` and side-effect `import` specifiers, both + * `./` and `../` (nested entries import `../../chunk.mjs`, and side-effect-only `import "../../chunk.mjs"`). + */ +function moduleGraphOf(entryPath: string): string[] { const visited = new Set(); const queue = [entryPath]; while (queue.length > 0) { const file = queue.shift()!; if (visited.has(file)) continue; visited.add(file); - const src = readFileSync(file, 'utf8'); - for (const m of src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)) { + for (const m of readFileSync(file, 'utf8').matchAll(/\b(?:from|import)\s*["'](\.{1,2}\/[^"']+?\.mjs)["']/g)) { queue.push(join(dirname(file), m[1]!)); } } - visited.delete(entryPath); + return [...visited]; +} + +/** A declaration entry and every declaration chunk it imports (a `.d.mts` file names its siblings as `.mjs`). */ +function declarationGraphOf(entryPath: string): string[] { + const visited = new Set(); + const queue = [entryPath]; + while (queue.length > 0) { + const file = queue.shift()!; + if (visited.has(file)) continue; + visited.add(file); + for (const m of readFileSync(file, 'utf8').matchAll(/\b(?:from|import)\s*["'](\.{1,2}\/[^"']+?)\.mjs["']/g)) { + queue.push(join(dirname(file), `${m[1]!}.d.mts`)); + } + } return [...visited]; } @@ -54,9 +78,8 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => { }); test('chunks transitively imported by dist/index.mjs contain no process-stdio runtime imports', () => { - const entry = join(distDir, 'index.mjs'); - for (const chunk of chunkImportsOf(entry)) { - expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( + for (const file of moduleGraphOf(join(distDir, 'index.mjs'))) { + expect({ file, content: readFileSync(file, 'utf8') }).not.toEqual( expect.objectContaining({ content: expect.stringMatching(NODE_ONLY) }) ); } @@ -69,11 +92,9 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => { test('runtime shims vendor default validator backends instead of requiring consumers to install them', () => { for (const shim of ['shimsNode.mjs', 'shimsWorkerd.mjs', 'shimsBrowser.mjs']) { - const entry = join(distDir, shim); - expect(readFileSync(entry, 'utf8')).not.toMatch(VALIDATOR_BACKEND_IMPORT); - - for (const chunk of chunkImportsOf(entry)) { - expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( + // The graph includes the shim entry itself. + for (const file of moduleGraphOf(join(distDir, shim))) { + expect({ file, content: readFileSync(file, 'utf8') }).not.toEqual( expect.objectContaining({ content: expect.stringMatching(VALIDATOR_BACKEND_IMPORT) }) ); } @@ -100,3 +121,57 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => { } }); }); + +describe('@modelcontextprotocol/server root and ext/skills are runtime-neutral', () => { + beforeAll(async () => { + await ensureBuilt(pkgDir); + }, 180_000); + + test.each(['index.mjs', 'ext/skills/index.mjs'])('%s and every chunk it imports have no node: imports', entry => { + for (const file of moduleGraphOf(join(distDir, entry))) { + expect({ file, content: readFileSync(file, 'utf8') }).not.toEqual( + expect.objectContaining({ content: expect.stringMatching(NODE_BUILTIN_IMPORT) }) + ); + } + }); + + test('ext/skills/node exports the folder adapter and is where the node: imports live', () => { + const node = join(distDir, 'ext/skills/node.mjs'); + expect(rootExportBlockOf(readFileSync(node, 'utf8'))).toMatch( + /\bloadSkills\b[\s\S]*\bnodeSkillFiles\b|\bnodeSkillFiles\b[\s\S]*\bloadSkills\b/ + ); + expect(moduleGraphOf(node).some(file => NODE_BUILTIN_IMPORT.test(readFileSync(file, 'utf8')))).toBe(true); + }); + + test('the root entry does not export the skills extension', () => { + expect(rootExportBlockOf(readFileSync(join(distDir, 'index.mjs'), 'utf8'))).not.toMatch( + /\b(?:SkillsExtension|SkillCatalog|loadSkills|nodeSkillFiles)\b/ + ); + }); + + test('dist/index.mjs and every chunk it imports carry no skills code and no @modelcontextprotocol/core/ext/skills import', () => { + // Users who never import ext/skills must not load (or bundle) the frontmatter parser or the skills schemas. + for (const file of moduleGraphOf(join(distDir, 'index.mjs'))) { + const content = readFileSync(file, 'utf8'); + expect({ file, content }).not.toEqual(expect.objectContaining({ content: expect.stringMatching(CORE_SKILLS_IMPORT) })); + expect({ file, content }).not.toEqual(expect.objectContaining({ content: expect.stringMatching(SKILLS_CODE) })); + } + }); + + test('the ext/skills entry graph is where the skills code and the core/ext/skills import live (guards the patterns)', () => { + const graph = moduleGraphOf(join(distDir, 'ext/skills/index.mjs')).map(file => readFileSync(file, 'utf8')); + expect(graph.some(content => CORE_SKILLS_IMPORT.test(content))).toBe(true); + expect(graph.some(content => SKILLS_CODE.test(content))).toBe(true); + }); + + test('the ext/skills declarations give subclasses no attach hook to override', () => { + // SEP-3371 registration (`extensions: [skills]`) will not go through attach(), so no hook may be public API. + const graph = declarationGraphOf(join(distDir, 'ext/skills/index.d.mts')); + expect(graph.some(file => /\bdeclare class SkillCatalog\b/.test(readFileSync(file, 'utf8')))).toBe(true); + for (const file of graph) { + expect({ file, content: readFileSync(file, 'utf8') }).not.toEqual( + expect.objectContaining({ content: expect.stringMatching(/\bafterAttach\b/) }) + ); + } + }); +}); diff --git a/packages/server/test/server/errorBrandConformance.test.ts b/packages/server/test/server/errorBrandConformance.test.ts index 8f8445d437..37800695e0 100644 --- a/packages/server/test/server/errorBrandConformance.test.ts +++ b/packages/server/test/server/errorBrandConformance.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import * as skills from '../../src/ext/skills/index'; +import * as skillsNode from '../../src/ext/skills/node'; import * as barrel from '../../src/index'; +/** Every public entry that can export an error class: the root and the skills extension's subpaths. */ +const SURFACES: ReadonlyArray> = [barrel, skills, skillsNode]; + /** * Server-side twin of the client's errorBrandConformance test: every error * class exported from this package's public surface must carry an own @@ -15,21 +20,25 @@ import * as barrel from '../../src/index'; /** Error classes exported on purpose without a brand. Justify every entry. */ const UNBRANDED_ALLOWLIST: ReadonlySet = new Set([]); -function exportedErrorClasses(mod: Record): Array<[string, Function]> { - return Object.entries(mod) - .filter((entry): entry is [string, Function] => { - const v = entry[1]; - return typeof v === 'function' && v !== Error && v.prototype instanceof Error; - }) - .sort(([a], [b]) => a.localeCompare(b)); +function exportedErrorClasses(surfaces: ReadonlyArray>): Array<[string, Function]> { + const found = new Map(); + for (const surface of surfaces) { + for (const [name, v] of Object.entries(surface)) { + if (typeof v !== 'function' || v === Error || !(v.prototype instanceof Error)) continue; + if (found.has(name) && found.get(name) !== v) throw new Error(`two entries export different error classes named ${name}`); + found.set(name, v); + } + } + return [...found].sort(([a], [b]) => a.localeCompare(b)); } describe('error brand conformance (server export surface)', () => { - const classes = exportedErrorClasses(barrel as Record); + const classes = exportedErrorClasses(SURFACES); it('finds the export surface (guards against a broken walker)', () => { expect(classes.length).toBeGreaterThanOrEqual(5); expect(classes.map(([n]) => n)).toContain('ProtocolError'); + expect(classes.map(([n]) => n)).toContain('SkillCatalogError'); }); it('every exported error class is branded (or explicitly allowlisted)', () => { @@ -76,7 +85,7 @@ describe('error brand conformance (server export surface)', () => { 'UnsupportedProtocolVersionError', 'UrlElicitationRequiredError' ]); - const SERVER_LOCAL_BRANDS: Record = {}; + const SERVER_LOCAL_BRANDS: Record = { SkillCatalogError: 'mcp.SkillCatalogError' }; it('pins every server-local brand string (add new entries here — renames must be deliberate)', () => { const brands = Object.fromEntries( @@ -86,4 +95,16 @@ describe('error brand conformance (server export surface)', () => { ); expect(brands).toEqual(SERVER_LOCAL_BRANDS); }); + + it('SkillCatalogError matches across module copies', async () => { + vi.resetModules(); + const foreign = await import('../../src/ext/skills/errors'); + // Guard the premise: a cached module would make the checks pass through prototype identity. + expect(foreign.SkillCatalogError).not.toBe(skills.SkillCatalogError); + const error = new foreign.SkillCatalogError([{ path: 'git-workflow/SKILL.md', message: 'invalid' }]); + expect(error instanceof skills.SkillCatalogError).toBe(true); + expect(skills.SkillCatalogError.isInstance(error)).toBe(true); + expect(error.name).toBe('SkillCatalogError'); + expect(new Error('plain') instanceof skills.SkillCatalogError).toBe(false); + }); }); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 184ab7a899..54f3389a5f 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -7,9 +7,13 @@ "*": ["./*"], "@modelcontextprotocol/core-internal": ["./node_modules/@modelcontextprotocol/core-internal/src/index.ts"], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], + "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/validators/ajv": [ "./node_modules/@modelcontextprotocol/core-internal/src/validators/ajvProvider.ts" ], diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index 88004cfc06..06137a4560 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -9,7 +9,9 @@ export default defineConfig({ 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', 'src/validators/ajv.ts', - 'src/validators/cfWorker.ts' + 'src/validators/cfWorker.ts', + 'src/ext/skills/index.ts', + 'src/ext/skills/node.ts' ], format: ['esm', 'cjs'], fixedExtension: true, @@ -26,8 +28,10 @@ export default defineConfig({ baseUrl: '.', paths: { 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.ts'], + '@modelcontextprotocol/core/ext/skills': ['../core/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], + '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } @@ -37,5 +41,5 @@ export default defineConfig({ // The schema modules live in @modelcontextprotocol/core (a real runtime dependency); the // bundled core-internal shims import them via the './internal' subpath, which must stay an // external import (explicit entry — the tsconfig paths alias would otherwise inline it). - external: ['@modelcontextprotocol/server/_shims', '@modelcontextprotocol/core/internal'] + external: ['@modelcontextprotocol/server/_shims', '@modelcontextprotocol/core/internal', '@modelcontextprotocol/core/ext/skills'] }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3cf72692..5369810c7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1088,6 +1088,28 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + examples/skills: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/hono': + specifier: workspace:* + version: link:../../packages/middleware/hono + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/sse-polling: dependencies: '@mcp-examples/shared': @@ -1558,6 +1580,9 @@ importers: vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + yaml: + specifier: ^2.8.3 + version: 2.8.3 packages/middleware/express: dependencies: @@ -1946,8 +1971,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.11 - version: 0.2.0-alpha.11(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.12 + version: 0.2.0-alpha.12(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../../packages/core-internal @@ -3240,8 +3265,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.11': - resolution: {integrity: sha512-imPK9tx5gQsL6ZKQq4MrsyDYfSaIwpRmX6+ogjbeAXs9LGvxkBxWcY7KcS7TvwaBk/ZiVWl6b/naF4q83UwDRA==} + '@modelcontextprotocol/conformance@0.2.0-alpha.12': + resolution: {integrity: sha512-eXtZFgmtZiU5SsvXty+RGXGnyIqEVm3GLU/HZmg1wMjtKOocPZZgD9HQKd30oJhio5UB0wFJmsc6r8u48Q+EYA==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -7862,7 +7887,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.11(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.12(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@octokit/rest': 22.0.1 diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 0f7090a82c..1eb2d8fdb0 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -12,10 +12,20 @@ # 2025 legs. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.11). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.12). Newer conformance releases # are adopted by deliberately bumping the pin and reconciling this file in the # same change. # +# alpha.11 -> alpha.12 reconciliation: the new SEP-2640 skills scenarios are +# extension scenarios, and --spec-version drops extension scenarios from a +# suite, so the two legs that read this file never run them. Their 2026-07-28 +# coverage comes from elsewhere. The server runner already runs extension +# scenarios at 2026-07-28 by default, in `test:conformance:server:extensions` +# (expected-failures.yaml). The client runner defaults them to 2025-11-25, so +# `test:conformance:client:skills:2026` runs each one with +# `--scenario --spec-version 2026-07-28 --force` (no baseline). Both +# sections stay empty. +# # alpha.10 -> alpha.11 reconciliation: `json-schema-2020-12-preservation` # (client leg; everythingClient negotiates via server/discover like tools_call) # passes at 2026-07-28 — the referee reports it as added-after-release, unscored diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 99045c1023..bedba50604 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,10 +2,28 @@ # CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.11). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.12). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling # this file in the same change. # +# alpha.11 -> alpha.12 reconciliation: the referee added the SEP-2640 +# (io.modelcontextprotocol/skills) extension scenarios, all passing, so none is +# baselined — server `sep-2640-skills-enumeration` / `-manifest` / `-directory` +# (against the SkillsExtension catalog in src/skills.ts) and client +# `sep-2640-client-verify-digest` / `-verify-size` / `-verify-frontmatter` / +# `-no-prefetch` (driven through SkillsClient by everythingClient.ts). No other +# scenario changed outcome. +# +# Extension scenarios sit outside the spec timeline, so each runner picks their +# protocol version: without --spec-version the server runner runs them at +# 2026-07-28 and the client runner at 2025-11-25. The `--suite all` legs that +# read this file therefore cover SEP-2640 server@2026-07-28 and +# client@2025-11-25. The other version on each side runs in +# `test:conformance:server:skills:2025` and `test:conformance:client:skills:2026` +# (one `--scenario --spec-version --force` run per scenario, no +# baseline, so any failure fails the step): --spec-version drops extension +# scenarios from a suite, and --force only applies to --scenario. +# # alpha.10 -> alpha.11 reconciliation: the referee added two scenarios, both # passing, so neither is baselined — `json-schema-2020-12-preservation` # (client; SEP-1613/SEP-2106 keyword round-trip, driven by everythingClient.ts) diff --git a/test/conformance/package.json b/test/conformance/package.json index 293180e6e3..7c237045d9 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -29,17 +29,19 @@ "test:conformance:client": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite core --expected-failures ./expected-failures.yaml", "test:conformance:client:all": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --expected-failures ./expected-failures.yaml", "test:conformance:client:2026": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml", + "test:conformance:client:skills:2026": "for s in sep-2640-client-verify-digest sep-2640-client-verify-size sep-2640-client-verify-frontmatter sep-2640-client-no-prefetch; do conformance client --command 'node --import tsx ./src/everythingClient.ts' --scenario $s --spec-version 2026-07-28 --force || exit 1; done", "test:conformance:client:run": "node --import tsx ./src/everythingClient.ts", "test:conformance:server": "scripts/run-server-conformance.sh --expected-failures ./expected-failures.yaml", "test:conformance:server:draft": "scripts/run-server-conformance.sh --suite draft --expected-failures ./expected-failures.yaml", "test:conformance:server:extensions": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml", "test:conformance:server:all": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml", "test:conformance:server:2026": "scripts/run-server-conformance.sh --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml", + "test:conformance:server:skills:2025": "for s in sep-2640-skills-enumeration sep-2640-skills-manifest sep-2640-skills-directory; do scripts/run-server-conformance.sh --scenario $s --spec-version 2025-11-25 --force || exit 1; done", "test:conformance:server:run": "node --import tsx ./src/everythingServer.ts", "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.11", + "@modelcontextprotocol/conformance": "0.2.0-alpha.12", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 450f1676b3..2c9849cf0a 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -20,6 +20,7 @@ import { requestJwtAuthorizationGrant, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient, SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; import * as z from 'zod/v4'; import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider'; @@ -904,6 +905,54 @@ async function runJsonSchema2020_12PreservationClient(serverUrl: string): Promis registerScenario('json-schema-2020-12-preservation', runJsonSchema2020_12PreservationClient); +// ============================================================================ +// SEP-2640 Skills +// ============================================================================ + +async function connectSkillsHost(serverUrl: string): Promise { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {}, ...(isModernConformanceRun() && { versionNegotiation: { mode: 'auto' as const } }) } + ); + await client.connect(new StreamableHTTPClientTransport(new URL(serverUrl))); + return client; +} + +// Load every listed skill the way a host does: read SKILL.md (verified by +// SkillsClient), and only if it verifies, read the skill's other files. +async function runSkillsLoadClient(serverUrl: string): Promise { + const client = await connectSkillsHost(serverUrl); + const skills = new SkillsClient(client); + const { skills: entries } = await skills.list(); + for (const entry of entries) { + try { + await skills.read(entry); + } catch (error) { + if (!(error instanceof SkillVerificationError)) throw error; + logger.debug(`not loading ${entry.uri}: ${error.message}`); + continue; + } + for (const resource of entry.resources === 'dynamic' ? [] : entry.resources) { + if (resource.uri !== entry.uri) await skills.read(entry, resource.uri); + } + } + await client.close(); +} + +// Listing alone must not read any skill file. +async function runSkillsNoPrefetchClient(serverUrl: string): Promise { + const client = await connectSkillsHost(serverUrl); + const { skills } = await new SkillsClient(client).list(); + logger.debug(`listed ${skills.length} skills without reading them`); + await client.close(); +} + +registerScenarios( + ['sep-2640-client-verify-digest', 'sep-2640-client-verify-size', 'sep-2640-client-verify-frontmatter'], + runSkillsLoadClient +); +registerScenario('sep-2640-client-no-prefetch', runSkillsNoPrefetchClient); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 6ede22b2bd..2aa3d31601 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -43,6 +43,8 @@ import type { Request, Response } from 'express'; import express from 'express'; import * as z from 'zod/v4'; +import { conformanceSkills } from './skills'; + // Server state const resourceSubscriptions = new Set(); const watchedResourceContent = 'Watched resource content'; @@ -167,6 +169,9 @@ function createMcpServer() { } ); + // SEP-2640: skills/list, skills/get, resources/directory/read and the skill:// resource template. + conformanceSkills.attach(mcpServer); + // Helper to send log messages using the underlying server function sendLog( level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency', diff --git a/test/conformance/src/skills.ts b/test/conformance/src/skills.ts new file mode 100644 index 0000000000..f2cce77395 --- /dev/null +++ b/test/conformance/src/skills.ts @@ -0,0 +1,34 @@ +/** + * Skills (SEP-2640) served by the conformance server, built with the SDK's + * own API. Started as typescript-sdk#2797 (tobi-oye). `pageSize: 1` makes + * skills/list and resources/directory/read page, so the pagination checks run. + */ +import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; + +export const conformanceSkills = await SkillsExtension.load( + memorySkillFiles({ + 'pdf-processing/SKILL.md': `--- +name: pdf-processing +description: Extract, fill, and assemble PDF documents. +metadata: + author: mcp-conformance +--- +# PDF processing + +Read references/FORMS.md before filling a form. +`, + 'pdf-processing/references/FORMS.md': `# Forms + +Fill the fields from top to bottom. +`, + 'pdf-processing/scripts/extract.py': 'print("extract")\n', + 'acme/billing/refunds/SKILL.md': `--- +name: refunds +description: Process customer refunds. +--- +# Refunds +`, + 'acme/billing/refunds/templates/refund-email.md': 'Your refund is on its way.\n' + }), + { pageSize: 1, strict: true } +); diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index a7617960f8..01cfe3ef37 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -9,13 +9,21 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/index.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json index c4e5ba6f4a..ac11a91396 100644 --- a/test/e2e/tsconfig.json +++ b/test/e2e/tsconfig.json @@ -9,9 +9,15 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/validators/cfWorker": [ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], diff --git a/test/helpers/tsconfig.json b/test/helpers/tsconfig.json index 781d610820..e6108eaf1f 100644 --- a/test/helpers/tsconfig.json +++ b/test/helpers/tsconfig.json @@ -9,9 +9,15 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] } } diff --git a/test/integration/test/__fixtures__/skillsStdioServer.ts b/test/integration/test/__fixtures__/skillsStdioServer.ts new file mode 100644 index 0000000000..7a58dd2258 --- /dev/null +++ b/test/integration/test/__fixtures__/skillsStdioServer.ts @@ -0,0 +1,27 @@ +/** + * A skills server over stdio: `loadSkills()` once, attached + * to every server the dual-era `serveStdio` entry creates. Spawned by + * `test/skills/skillsEndToEnd.test.ts`. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +const root = process.argv[2]; +if (root === undefined) throw new Error('usage: skillsStdioServer.ts '); + +const skills = await loadSkills(root); +const handle = serveStdio(() => { + const mcp = new McpServer({ name: 'skills-stdio-fixture', version: '1.0.0' }); + skills.attach(mcp); + return mcp; +}); + +const exit = async () => { + await handle.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}; + +process.on('SIGINT', exit); +process.on('SIGTERM', exit); diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index 150a938ccc..a30831ff67 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -15,6 +15,7 @@ import * as os from 'node:os'; import path from 'node:path'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const READINESS_TIMEOUT_MS = 60_000; @@ -27,6 +28,14 @@ const SHUTDOWN_GRACE_MS = 5000; */ const SERVER_VERSION_NONCE = randomUUID(); +/** The skill the worker serves on `/skills`, from memory (Workers have no file system). */ +const RELEASE_NOTES_MD = `--- +name: release-notes +description: Write release notes from merged pull requests. +--- +# Release notes +`; + /** * The workspace's own wrangler installation (pinned by pnpm-lock). Running it directly — * instead of `npm install`-ing wrangler into the temp project and going through `npx` — @@ -297,13 +306,18 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => { }; fs.writeFileSync(path.join(tempDir, 'wrangler.jsonc'), JSON.stringify(wranglerConfig, null, 2)); - // Write server source + // Write server source. `/skills` also serves a skills catalog, loaded once at module scope + // and attached to each per-request server (the shape the server guide recommends for + // Workers); `/runtime` reports the globals the skills extension feature-detects. const serverSource = ` import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; import { z } from 'zod'; +const skills = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': ${JSON.stringify(RELEASE_NOTES_MD)} })); + // A stateless transport serves one request: connect a new pair for each. -async function connectPair() { +async function connectPair(withSkills) { const server = new McpServer({ name: "test-server", version: "${SERVER_VERSION_NONCE}" }); server.registerTool("greet", { @@ -312,6 +326,7 @@ async function connectPair() { }, async ({ name }) => ({ content: [{ type: "text", text: "Hello, " + name + "!" }] })); + if (withSkills) skills.attach(server); const transport = new WebStandardStreamableHTTPServerTransport(); await server.connect(transport); @@ -320,7 +335,11 @@ async function connectPair() { export default { fetch: async (request) => { - const transport = await connectPair(); + const { pathname } = new URL(request.url); + if (pathname === '/runtime') { + return new Response(JSON.stringify({ weakRef: typeof WeakRef }), { headers: { 'content-type': 'application/json' } }); + } + const transport = await connectPair(pathname === '/skills'); return transport.handleRequest(request); } }; @@ -378,4 +397,24 @@ export default { await client.close(); }, 30_000); + + it('serves a skills catalog attached per request, on a compatibility date without WeakRef', async () => { + // The premise: `enable_weak_ref` is on by default only from compatibility date 2025-05-05. + const response = await fetch(`http://127.0.0.1:${port}/runtime`); + expect(await response.json()).toEqual({ weakRef: 'undefined' }); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/skills`))); + const skills = new SkillsClient(client); + expect(skills.supported).toBe(true); + + const { skills: entries, invalid } = await skills.list(); + expect(invalid).toEqual([]); + expect(entries.map(skill => skill.uri)).toEqual(['skill://release-notes/SKILL.md']); + const manifest = await skills.read(entries[0]!); + expect(manifest).toMatchObject({ verified: true, text: RELEASE_NOTES_MD }); + expect(manifest.frontmatter).toEqual({ name: 'release-notes', description: 'Write release notes from merged pull requests.' }); + + await client.close(); + }, 30_000); }); diff --git a/test/integration/test/skills/skillTree.ts b/test/integration/test/skills/skillTree.ts new file mode 100644 index 0000000000..b7e5dee99c --- /dev/null +++ b/test/integration/test/skills/skillTree.ts @@ -0,0 +1,51 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const PNG = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]); + +/** + * A skills folder with everything that tends to break: a Windows-authored + * SKILL.md (BOM + CRLF), a nested skill under organisational folders, a file + * name with a space and a binary file. Written to a temp dir at test time, so + * no formatter or git line-ending rule can touch the bytes. + */ +export const SKILL_TREE: Readonly> = { + 'git-workflow/SKILL.md': `--- +name: git-workflow +description: Branching and review conventions. +--- +# Git workflow + +Read references/REVIEW.md before opening a PR. +`, + 'git-workflow/references/REVIEW.md': `# Review + +- tests pass +`, + 'acme/billing/refunds/SKILL.md': '---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\n# Refunds\r\n', + 'acme/billing/refunds/templates/Purchase Order.md': '# PO\n', + 'acme/billing/refunds/assets/logo.png': PNG, + 'acme/billing/refunds/escalation/SKILL.md': `--- +name: escalation +description: Escalate a refund. +--- +# Escalation +` +}; + +export const GIT = 'skill://git-workflow/SKILL.md'; +export const REFUNDS = 'skill://acme/billing/refunds/SKILL.md'; +export const ESCALATION = 'skill://acme/billing/refunds/escalation/SKILL.md'; +export const PURCHASE_ORDER = 'skill://acme/billing/refunds/templates/Purchase%20Order.md'; + +/** Writes `files` under a fresh temp dir and returns its path. */ +export async function writeSkillTree(files: Readonly> = SKILL_TREE): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'mcp-skills-it-')); + for (const [relative, content] of Object.entries(files)) { + const target = path.join(root, ...relative.split('/')); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + } + return root; +} diff --git a/test/integration/test/skills/skillsEndToEnd.test.ts b/test/integration/test/skills/skillsEndToEnd.test.ts new file mode 100644 index 0000000000..e7970b02fe --- /dev/null +++ b/test/integration/test/skills/skillsEndToEnd.test.ts @@ -0,0 +1,206 @@ +/** + * Skills over MCP end to end, through public import paths only: a folder on + * disk served by `loadSkills`, read and verified by `SkillsClient`, over HTTP + * (`createMcpHandler`, one server per request) and stdio (a child process), on + * both protocol versions. + */ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import type { SkillCatalog } from '@modelcontextprotocol/server/ext/skills'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { ESCALATION, GIT, PURCHASE_ORDER, REFUNDS, writeSkillTree } from './skillTree'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; +const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__'); + +function clientFor(revision: string): Client { + return new Client( + { name: 'skills-host', version: '1.0.0' }, + revision === MODERN ? { versionNegotiation: { mode: { pin: MODERN } } } : {} + ); +} + +async function overHttp(catalog: SkillCatalog, revision: string, onServer: () => void = () => {}): Promise { + const handler = createMcpHandler(() => { + onServer(); + const mcp = new McpServer({ name: 'skills-http', version: '1.0.0' }); + catalog.attach(mcp); + return mcp; + }); + const client = clientFor(revision); + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { fetch: (url, init) => handler.fetch(new Request(url, init)) }) + ); + return client; +} + +async function overStdio(root: string, revision: string): Promise { + const client = clientFor(revision); + await client.connect( + new StdioClientTransport({ command: process.execPath, args: ['--import', 'tsx', 'skillsStdioServer.ts', root], cwd: FIXTURES_DIR }) + ); + return client; +} + +let root: string; +let catalog: SkillCatalog; + +beforeAll(async () => { + root = await writeSkillTree(); + catalog = await loadSkills(root); +}); + +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe.each([ + ['http', LEGACY], + ['http', MODERN], + ['stdio', LEGACY], + ['stdio', MODERN] +])('skills over %s on %s', (transport, revision) => { + let client: Client; + let skills: SkillsClient; + + beforeAll(async () => { + client = transport === 'http' ? await overHttp(catalog, revision) : await overStdio(root, revision); + skills = new SkillsClient(client); + }, 30_000); + + afterAll(async () => { + await client.close(); + }); + + it('declares the extension with directoryRead', () => { + expect(skills.supported).toBe(true); + expect(skills.directoryRead).toBe(true); + }); + + it('lists every skill, and every file of every skill verifies', async () => { + const { skills: entries, invalid } = await skills.list(); + expect(invalid).toEqual([]); + expect(entries.map(entry => entry.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + for (const entry of entries) { + const manifest = await skills.read(entry); + expect(manifest).toMatchObject({ verified: true, frontmatter: entry.frontmatter }); + for (const resource of entry.resources === 'dynamic' ? [] : entry.resources) { + expect(await skills.read(entry, resource.uri)).toMatchObject({ uri: resource.uri, verified: true }); + } + } + }); + + it('keeps a Windows-authored SKILL.md byte for byte', async () => { + const refunds = await skills.get(REFUNDS); + const manifest = await skills.read(refunds); + expect(manifest.text?.startsWith('---\r\n')).toBe(true); + expect(manifest.frontmatter).toEqual({ name: 'refunds', description: 'Refund workflow.' }); + }); + + it('gets a skill and reads files by relative path, including names with a space', async () => { + const git = await skills.get(GIT); + expect(await skills.read(git, 'references/REVIEW.md')).toMatchObject({ text: '# Review\n\n- tests pass\n', verified: true }); + const refunds = await skills.get(REFUNDS); + expect(await skills.read(refunds, 'templates/Purchase Order.md')).toMatchObject({ uri: PURCHASE_ORDER, verified: true }); + }); + + it('reads a skill directory', async () => { + const children = await skills.readDirectory('skill://acme/billing/refunds'); + expect(children.map(child => child.name)).toEqual(['SKILL.md', 'assets', 'escalation', 'templates']); + }); + + it('sends caching fields on 2026-07-28 only', async () => { + const listing = await skills.list(); + if (revision === MODERN) expect(listing).toMatchObject({ ttlMs: 0, cacheScope: 'private' }); + else expect(listing).not.toHaveProperty('ttlMs'); + }); + + it('lists only SKILL.md files in resources/list', async () => { + const { resources } = await client.listResources(); + expect(resources.map(resource => resource.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + }); +}); + +describe('one catalog behind per-request HTTP servers', () => { + it('serves every request from a fresh server', async () => { + let servers = 0; + const client = await overHttp(catalog, MODERN, () => { + servers++; + }); + const skills = new SkillsClient(client); + const { skills: entries } = await skills.list(); + for (const entry of entries) await skills.read(entry); + expect(servers).toBeGreaterThan(3); + await client.close(); + }); + + it('refuses an edited file until reload(), then serves the new version', async () => { + const editable = await writeSkillTree(); + const own = await loadSkills(editable, { onWarning: () => {} }); + const client = await overHttp(own, MODERN); + const skills = new SkillsClient(client); + const before = await skills.get(GIT); + await writeFile(path.join(editable, 'git-workflow', 'references', 'REVIEW.md'), '# Review v2\n'); + await expect(skills.read(before, 'references/REVIEW.md')).rejects.toMatchObject({ code: -32_603 }); + expect(await own.reload()).toEqual({ added: [], removed: [], changed: [GIT], listChanged: false }); + const after = await skills.get(GIT); + expect(await skills.read(after, 'references/REVIEW.md')).toMatchObject({ text: '# Review v2\n', verified: true }); + await client.close(); + await rm(editable, { recursive: true, force: true }); + }); + + it('tells listen streams about a listing change through handler.notify, which reload() alone cannot reach', async () => { + const editable = await writeSkillTree(); + const own = await loadSkills(editable, { onWarning: () => {} }); + const handler = createMcpHandler( + () => { + const mcp = new McpServer({ name: 'skills-http', version: '1.0.0' }); + own.attach(mcp); + return mcp; + }, + { keepAliveMs: 0 } + ); + const client = clientFor(MODERN); + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)) + }) + ); + const seen: string[] = []; + client.setNotificationHandler('notifications/resources/list_changed', () => void seen.push('resources/list_changed')); + const subscription = await client.listen({ resourcesListChanged: true }); + expect(subscription.honoredFilter).toEqual({ resourcesListChanged: true }); + + await mkdir(path.join(editable, 'aaa')); + await writeFile( + path.join(editable, 'aaa', 'SKILL.md'), + `--- +name: aaa +description: Sorts first. +--- +` + ); + const changes = await own.reload(); + expect(changes).toMatchObject({ added: ['skill://aaa/SKILL.md'], listChanged: true }); + // Every server this handler built has served its one request and closed: nothing reaches the stream. + await new Promise(resolve => setTimeout(resolve, 30)); + expect(seen).toEqual([]); + + // What docs/servers/skills.md tells servers behind createMcpHandler to do. + if (changes.listChanged) handler.notify.resourcesChanged(); + await vi.waitFor(() => expect(seen).toEqual(['resources/list_changed'])); + + await subscription.close(); + await client.close(); + await handler.close(); + await rm(editable, { recursive: true, force: true }); + }); +}); diff --git a/test/integration/test/skills/skillsWire.test.ts b/test/integration/test/skills/skillsWire.test.ts new file mode 100644 index 0000000000..83aa40bc21 --- /dev/null +++ b/test/integration/test/skills/skillsWire.test.ts @@ -0,0 +1,194 @@ +/** + * Wire behaviour of the Skills extension, as a client sees it. This suite is + * the contract for the SEP-3371 version (JSON-RPC middleware instead of a + * resource template and handlers): it must pass unchanged against it. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import type { SkillCatalog, SkillSource } from '@modelcontextprotocol/server/ext/skills'; +import { memorySkillFiles, skillEntry, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; +const EXTENSION = 'io.modelcontextprotocol/skills'; +const SERVER_INFO = 'io.modelcontextprotocol/serverInfo'; +const Loose = z.looseObject({}); + +const GIT_SKILL_MD = `--- +name: git-workflow +description: Branching and review conventions. +--- +# Git workflow +`; +const REVIEW_MD = '# Review\n'; +const NOTES_MD = `--- +name: notes +description: Team notes. +--- +# Notes +`; +const GIT = 'skill://git-workflow/SKILL.md'; +const REVIEW = 'skill://git-workflow/references/REVIEW.md'; + +let catalog: SkillCatalog; +const open: Client[] = []; + +beforeAll(async () => { + catalog = await SkillsExtension.load( + memorySkillFiles({ 'git-workflow/SKILL.md': GIT_SKILL_MD, 'git-workflow/references/REVIEW.md': REVIEW_MD }) + ); +}); + +afterEach(async () => { + await Promise.all(open.splice(0).map(client => client.close())); +}); + +async function connect(revision: string, build: (mcp: McpServer) => void): Promise { + const handler = createMcpHandler(() => { + const mcp = new McpServer({ name: 'skills-wire', version: '1.0.0' }); + build(mcp); + return mcp; + }); + const client = new Client( + { name: 'wire-client', version: '1.0.0' }, + revision === MODERN ? { versionNegotiation: { mode: { pin: MODERN } } } : {} + ); + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { fetch: (url, init) => handler.fetch(new Request(url, init)) }) + ); + open.push(client); + return client; +} + +function raw(client: Client, method: string, params?: Record): Promise> { + return client.request({ method, ...(params !== undefined && { params }) }, Loose); +} + +async function notesSource(withListing: boolean): Promise { + const notes = await skillEntry('notes', { 'SKILL.md': NOTES_MD }); + return { + list: async () => ({ skills: [notes] }), + get: async uri => (uri === notes.uri ? notes : undefined), + read: async uri => (uri === notes.uri ? { text: NOTES_MD, mimeType: 'text/markdown' } : undefined), + ...(withListing && { listResources: async () => [{ uri: notes.uri, name: 'notes', mimeType: 'text/markdown' }] }) + }; +} + +describe.each([LEGACY, MODERN])('skills wire behaviour on %s', revision => { + // A 2026-07-28 result carries the cache fields and, in `_meta`, the server's identity (ResultMetaObject). + const envelope = + revision === MODERN ? { _meta: { [SERVER_INFO]: { name: 'skills-wire', version: '1.0.0' } }, ttlMs: 0, cacheScope: 'private' } : {}; + + it('declares the extension next to resources', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + expect(client.getServerCapabilities()).toMatchObject({ resources: {}, extensions: { [EXTENSION]: { directoryRead: true } } }); + }); + + it('publishes one resource template', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + const { resourceTemplates } = await raw(client, 'resources/templates/list'); + expect(resourceTemplates).toEqual([{ name: EXTENSION, uriTemplate: 'skill://{+path}' }]); + }); + + it('lists each SKILL.md in resources/list', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + const { resources } = await raw(client, 'resources/list'); + expect(resources).toEqual([ + { + uri: GIT, + name: 'git-workflow', + description: 'Branching and review conventions.', + mimeType: 'text/markdown', + size: GIT_SKILL_MD.length + } + ]); + }); + + it('serves files through resources/read', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + const { contents } = await raw(client, 'resources/read', { uri: REVIEW }); + expect(contents).toEqual([{ uri: REVIEW, mimeType: 'text/markdown', text: REVIEW_MD }]); + }); + + it('answers skills/list, skills/get and resources/directory/read', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + expect(await raw(client, 'skills/list')).toEqual({ skills: catalog.entries, ...envelope }); + expect(await raw(client, 'skills/get', { uri: GIT })).toEqual({ skill: catalog.entries[0], ...envelope }); + expect(await raw(client, 'resources/directory/read', { uri: 'skill://git-workflow' })).toEqual({ + resources: [ + { uri: GIT, name: 'SKILL.md', mimeType: 'text/markdown', size: GIT_SKILL_MD.length }, + { uri: 'skill://git-workflow/references', name: 'references', mimeType: 'inode/directory' } + ], + ...envelope + }); + }); + + it('uses the agreed error codes', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + await expect(raw(client, 'resources/read', { uri: 'skill://nope/x.md' })).rejects.toMatchObject({ code: -32_602 }); + await expect(raw(client, 'resources/read', { uri: 'skill://git-workflow' })).rejects.toMatchObject({ + code: -32_602, + message: expect.stringMatching(/is a directory/) + }); + await expect(raw(client, 'skills/get', { uri: 'skill://nope/SKILL.md' })).rejects.toMatchObject({ code: -32_602 }); + await expect(raw(client, 'skills/list', { cursor: 'garbage' })).rejects.toMatchObject({ code: -32_602 }); + }); + + it('lets an app resource registered first win at its URI (documented: do not do this)', async () => { + const client = await connect(revision, mcp => { + mcp.registerResource('override', REVIEW, { mimeType: 'text/plain' }, () => ({ contents: [{ uri: REVIEW, text: 'app wins' }] })); + catalog.attach(mcp); + }); + const { contents } = await raw(client, 'resources/read', { uri: REVIEW }); + expect(contents).toEqual([{ uri: REVIEW, text: 'app wins' }]); + const { resources } = await raw(client, 'resources/list'); + expect((resources as Array<{ uri: string }>).map(resource => resource.uri)).toEqual([REVIEW, GIT]); + }); + + it('lists nothing in resources/list for a source without listResources, and no directoryRead', async () => { + const source = new SkillsExtension({ source: await notesSource(false) }); + const client = await connect(revision, mcp => source.attach(mcp)); + expect(client.getServerCapabilities()?.extensions).toEqual({ [EXTENSION]: {} }); + const { resources } = await raw(client, 'resources/list'); + expect(resources).toEqual([]); + const directoryRead = raw(client, 'resources/directory/read', { uri: 'skill://notes' }); + // 2026-07-28 Streamable HTTP answers an unimplemented method with HTTP 404 and a -32601 body; the client transport surfaces a 404 as SdkHttpError. + await (revision === MODERN + ? expect(directoryRead).rejects.toMatchObject({ data: { status: 404, text: expect.stringContaining('-32601') } }) + : expect(directoryRead).rejects.toMatchObject({ code: -32_601 })); + }); + + it('lists what listResources returns, and the host verifies source-built skills', async () => { + const source = new SkillsExtension({ source: await notesSource(true) }); + const client = await connect(revision, mcp => source.attach(mcp)); + const { resources } = await raw(client, 'resources/list'); + expect(resources).toEqual([{ uri: 'skill://notes/SKILL.md', name: 'notes', mimeType: 'text/markdown' }]); + const skills = new SkillsClient(client); + const { + skills: [notes] + } = await skills.list(); + expect(await skills.read(notes!)).toMatchObject({ verified: true, frontmatter: { name: 'notes' } }); + }); + + it('serves a source under another scheme with baseUri', async () => { + const notes = await skillEntry('notes', { 'SKILL.md': NOTES_MD }, { baseUri: 'github://owner/repo/skills' }); + const source = new SkillsExtension({ + source: { + list: async () => ({ skills: [notes] }), + get: async uri => (uri === notes.uri ? notes : undefined), + read: async uri => (uri === notes.uri ? { text: NOTES_MD } : undefined) + }, + baseUri: 'github://owner/repo/skills' + }); + const client = await connect(revision, mcp => source.attach(mcp)); + const { resourceTemplates } = await raw(client, 'resources/templates/list'); + expect(resourceTemplates).toEqual([{ name: EXTENSION, uriTemplate: 'github://owner/repo/skills/{+path}' }]); + expect(await new SkillsClient(client).read(notes)).toMatchObject({ + uri: 'github://owner/repo/skills/notes/SKILL.md', + verified: true + }); + }); +}); diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json index 2b016fd288..a15fa93ad5 100644 --- a/test/integration/tsconfig.json +++ b/test/integration/tsconfig.json @@ -9,9 +9,15 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/validators/ajv": [ "./node_modules/@modelcontextprotocol/core-internal/src/validators/ajvProvider.ts" ], @@ -28,6 +34,9 @@ "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/server/validators/ajv": ["./node_modules/@modelcontextprotocol/server/src/validators/ajv.ts"], "@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"], + "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills/node": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/node.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"],