diff --git a/.changeset/skills-extension.md b/.changeset/skills-extension.md new file mode 100644 index 0000000000..dc8d7c4b6f --- /dev/null +++ b/.changeset/skills-extension.md @@ -0,0 +1,13 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +The MCP Skills extension (`io.modelcontextprotocol/skills`, SEP-2640) as a pair of extensions. + +`@modelcontextprotocol/server/ext/skills`: `new SkillsExtension(source)` in `ServerOptions.extensions` declares the extension and the `resources` capability, and serves `skills/list` and `skills/get` from a `SkillSource`, plus `resources/directory/read` (declared as `directoryRead`) when the source implements `readDirectory`. Entries are checked against the specification's structural rules before they are sent. Skill files stay ordinary resources; `skillResourceOf` computes the digest and size an entry lists for each. + +`@modelcontextprotocol/client/ext/skills`: `new SkillsClientExtension()` in `ClientOptions.extensions` wraps `list`, `get` and `readDirectory`, each refused unless the server declared support, and `read(skill, uri)`, which fetches a skill file and verifies it is listed and matches the entry's size and digest. + +Wire types and zod schemas live at `@modelcontextprotocol/core-internal/ext/skills` and are re-exported from both subpaths. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index f1d5f96cdf..55188dbfe2 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -27,6 +27,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Elicitation', link: '/servers/elicitation' }, { text: 'Sampling (sunset)', link: '/servers/sampling' }, { text: 'Input required', link: '/servers/input-required' }, + { text: 'Skills (extension)', link: '/servers/skills' }, { text: 'Notifications', link: '/servers/notifications' }, { text: 'Errors', link: '/servers/errors' } ] @@ -53,6 +54,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Handle server requests', link: '/clients/server-requests' }, { text: 'Roots (sunset)', link: '/clients/roots' }, { text: 'Subscriptions', link: '/clients/subscriptions' }, + { text: 'Skills (extension)', link: '/clients/skills' }, { text: 'OAuth', link: '/clients/oauth' }, { text: 'Machine auth', link: '/clients/machine-auth' }, { text: 'Middleware', link: '/clients/middleware' }, diff --git a/docs/clients/skills.md b/docs/clients/skills.md new file mode 100644 index 0000000000..a77372ce9b --- /dev/null +++ b/docs/clients/skills.md @@ -0,0 +1,39 @@ +--- +shape: how-to +--- + +# Skills (extension) + +The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/). `@modelcontextprotocol/client/ext/skills` is the client side, as a client extension: it lists and gets skill entries, reads directories, and reads skill files verified against their entry. + +## Install the extension + +```ts +import { Client } from '@modelcontextprotocol/client'; +import { SkillsClientExtension } from '@modelcontextprotocol/client/ext/skills'; + +const skills = new SkillsClientExtension(); +const client = new Client({ name: 'host', version: '1.0.0' }, { extensions: [skills] }); +await client.connect(transport); +``` + +Every method refuses with `CapabilityNotSupported` unless the server declared the extension and the `resources` capability. `readDirectory` also needs the server to declare `directoryRead`. + +## List and get skills + +`list` returns one page of entries. Pass `nextCursor` back as `cursor` for the next page. An entry is the full manifest: frontmatter, plus every file with its digest and size. `get(uri)` returns the entry for one skill by the URI of its `SKILL.md`, whether or not the listing included it. + +```ts +const { skills: entries, nextCursor } = await skills.list(); +const { skill } = await skills.get('skill://git-workflow/SKILL.md'); +``` + +## Read a skill file + +`read(skill, uri)` fetches the file with `resources/read` and checks it against the entry. The file must be listed, and its size and SHA-256 digest must match. Any mismatch throws `SdkError` with `InvalidResult`. Refresh the entry with `get` and ask the user to approve the skill again. + +```ts +const manifest = await skills.read(skill, skill.uri); +``` + +A skill with `resources: 'dynamic'` has nothing to verify against, so its files come back as read. The extension leaves two checks to the host: comparing a `SKILL.md`'s frontmatter with the entry (this needs a YAML parser), and the specification's [security requirements](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx#security-considerations) for loading skills into a model. diff --git a/docs/servers/skills.md b/docs/servers/skills.md new file mode 100644 index 0000000000..c303338958 --- /dev/null +++ b/docs/servers/skills.md @@ -0,0 +1,57 @@ +--- +shape: how-to +--- + +# Skills (extension) + +The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/): directories of instructions, each with a `SKILL.md`, that a host can load into its model. `@modelcontextprotocol/server/ext/skills` is the server side, as a [server extension](../advanced/extensions.md). It serves `skills/list`, `skills/get` and, optionally, `resources/directory/read`. Your server supplies the skill entries through a `SkillSource`, and serves the skill files as ordinary resources. + +## Describe a skill + +A skill entry names its `SKILL.md`, repeats that file's frontmatter, and lists every file of the skill with its SHA-256 digest and size. `skillResourceOf(uri, content)` computes a file's line in that list. + +```ts +import type { Skill } from '@modelcontextprotocol/server/ext/skills'; +import { skillResourceOf } from '@modelcontextprotocol/server/ext/skills'; + +const files = { + 'skill://git-workflow/SKILL.md': '---\nname: git-workflow\ndescription: Follow our Git conventions\n---\n\nBranch from main.\n' +}; + +const gitWorkflow: Skill = { + uri: 'skill://git-workflow/SKILL.md', + frontmatter: { name: 'git-workflow', description: 'Follow our Git conventions' }, + resources: await Promise.all(Object.entries(files).map(([uri, text]) => skillResourceOf(uri, text))) +}; +``` + +The frontmatter must match the `SKILL.md` exactly, and the URI's last directory must equal `frontmatter.name`. The server checks the second rule, and the entry's other structural rules, and answers `-32603` rather than send an entry that breaks one. A skill generated per request, with no stable digests, sets `resources: 'dynamic'`. + +## Install the extension + +`SkillsExtension` takes the source. `list` returns a page of entries (an empty or partial listing is allowed). `get` answers for any skill the server serves, listed or not, and returns `undefined` for anything else, which the client receives as `-32602`. + +```ts +import { McpServer } from '@modelcontextprotocol/server'; +import { SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; + +const skills = new SkillsExtension( + { + list: () => ({ skills: [gitWorkflow] }), + get: ({ uri }) => (uri === gitWorkflow.uri ? gitWorkflow : undefined) + }, + { cacheHint: { ttlMs: 300_000, cacheScope: 'public' } } +); + +const server = new McpServer({ name: 'skills-server', version: '1.0.0' }, { extensions: [skills] }); + +for (const [uri, text] of Object.entries(files)) { + server.registerResource(uri, uri, { mimeType: 'text/markdown' }, () => ({ contents: [{ uri, mimeType: 'text/markdown', text }] })); +} +``` + +The server declares `io.modelcontextprotocol/skills` under `capabilities.extensions`, along with the `resources` capability the extension requires. `cacheHint` sets the `ttlMs` and `cacheScope` on `skills/list` and `skills/get` results. Both default to `0` and `'private'`. + +## Serve directory reads + +A source that implements `readDirectory` also gets `resources/directory/read`, and the server declares `directoryRead: true`. It returns the direct children of a directory such as `skill://pdf-processing/templates`, with subdirectories marked `mimeType: 'inode/directory'`, or `undefined` when the URI is not a directory. diff --git a/packages/client/package.json b/packages/client/package.json index 29bdd4f8db..e44cf3735b 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", @@ -115,6 +125,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "ext/skills": [ + "dist/ext/skills/index.d.mts" ] } }, diff --git a/packages/client/src/ext/skills/index.ts b/packages/client/src/ext/skills/index.ts new file mode 100644 index 0000000000..ed79c04067 --- /dev/null +++ b/packages/client/src/ext/skills/index.ts @@ -0,0 +1,44 @@ +/** + * `@modelcontextprotocol/client/ext/skills` — the client side of the MCP + * Skills extension (`io.modelcontextprotocol/skills`, SEP-2640). + * + * `SkillsClientExtension` wraps `skills/list`, `skills/get` and + * `resources/directory/read`, and reads skill files verified against their + * entry's digest and size. + */ + +export { SkillsClientExtension } from './skillsClientExtension'; +export type { + GetSkillParams, + GetSkillResult, + ListSkillsParams, + ListSkillsResult, + ReadResourceDirectoryParams, + ReadResourceDirectoryResult, + Skill, + SkillFrontmatter, + SkillResource, + SkillsCacheScope, + SkillsExtensionCapability +} from '@modelcontextprotocol/core-internal/ext/skills'; +export { + getSkillParamsSchema, + getSkillResultSchema, + listSkillsParamsSchema, + listSkillsResultSchema, + readResourceDirectoryParamsSchema, + readResourceDirectoryResultSchema, + skillFrontmatterSchema, + skillResourceSchema, + skillSchema, + skillsExtensionCapabilitySchema +} from '@modelcontextprotocol/core-internal/ext/skills'; +export { + DIRECTORY_MIME_TYPE, + MAX_SKILL_RESOURCES, + MAX_SKILL_TOTAL_BYTES, + SKILL_MANIFEST_FILENAME, + skillDigest, + skillResourceOf, + SKILLS_EXTENSION_ID +} from '@modelcontextprotocol/core-internal/ext/skills'; diff --git a/packages/client/src/ext/skills/skillsClientExtension.ts b/packages/client/src/ext/skills/skillsClientExtension.ts new file mode 100644 index 0000000000..7c1bc2a1b7 --- /dev/null +++ b/packages/client/src/ext/skills/skillsClientExtension.ts @@ -0,0 +1,133 @@ +/** + * `SkillsClientExtension` — the client side of the MCP Skills extension + * (`io.modelcontextprotocol/skills`, SEP-2640) as a {@linkcode ClientExtension}. + * It wraps `skills/list`, `skills/get` and `resources/directory/read`, each + * refused unless the server declared support, and `read`, which fetches a + * skill file with `resources/read` and verifies it against the skill's entry. + * + * ```ts + * const skills = new SkillsClientExtension(); + * const client = new Client(info, { extensions: [skills] }); + * await client.connect(transport); + * + * const { skills: entries } = await skills.list(); + * const manifest = await skills.read(entries[0], entries[0].uri); + * ``` + * + * One extension instance serves one client: `install` binds it. + */ + +import type { BlobResourceContents, RequestOptions, TextResourceContents } from '@modelcontextprotocol/core-internal'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import type { + GetSkillResult, + ListSkillsParams, + ListSkillsResult, + ReadResourceDirectoryResult, + Skill, + SkillsExtensionCapability +} from '@modelcontextprotocol/core-internal/ext/skills'; +import { + getSkillResultSchema, + listSkillsResultSchema, + readResourceDirectoryResultSchema, + skillDigest, + skillFileBytes, + SKILLS_EXTENSION_ID, + skillsExtensionCapabilitySchema +} from '@modelcontextprotocol/core-internal/ext/skills'; + +import type { Client } from '../../client/client'; +import type { ClientExtension } from '../../client/extension'; + +const verificationFailure = (uri: string, reason: string): never => { + throw new SdkError(SdkErrorCode.InvalidResult, `Skill file ${uri} failed verification: ${reason}`); +}; + +const rawBytes = (content: TextResourceContents | BlobResourceContents): Uint8Array => + 'blob' in content ? Uint8Array.from(atob(content.blob), char => char.codePointAt(0) ?? 0) : skillFileBytes(content.text); + +export class SkillsClientExtension implements ClientExtension { + readonly id = SKILLS_EXTENSION_ID; + #client: Client | undefined; + + install(client: Client): void { + if (this.#client !== undefined) throw new Error('SkillsClientExtension is already installed on a client'); + this.#client = client; + } + + get client(): Client { + if (this.#client === undefined) throw new SdkError(SdkErrorCode.NotConnected, 'SkillsClientExtension is not installed on a client'); + return this.#client; + } + + /** The extension settings the server declared, or `undefined` when it declared none. */ + get capability(): SkillsExtensionCapability | undefined { + const declared = this.client.getServerCapabilities()?.extensions?.[SKILLS_EXTENSION_ID]; + const parsed = skillsExtensionCapabilitySchema.safeParse(declared); + return parsed.success ? parsed.data : undefined; + } + + /** `skills/list`: one page of entries. Pass `nextCursor` back as `cursor` for the next. */ + async list(params?: ListSkillsParams, options?: RequestOptions): Promise { + this.#require('skills/list'); + return (await this.client.request( + { method: 'skills/list', params: { ...params } }, + listSkillsResultSchema, + options + )) as ListSkillsResult; + } + + /** `skills/get`: the entry for the skill whose `SKILL.md` is at `uri`, listed or not. */ + async get(uri: string, options?: RequestOptions): Promise { + this.#require('skills/get'); + return (await this.client.request({ method: 'skills/get', params: { uri } }, getSkillResultSchema, options)) as GetSkillResult; + } + + /** `resources/directory/read`: the direct children of a directory resource. Refused unless the server declared `directoryRead`. */ + async readDirectory(uri: string, cursor?: string, options?: RequestOptions): Promise { + this.#require('resources/directory/read'); + if (this.capability?.directoryRead !== true) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Server does not support resources/directory/read'); + } + const params = { uri, ...(cursor !== undefined && { cursor }) }; + return (await this.client.request( + { method: 'resources/directory/read', params }, + readResourceDirectoryResultSchema, + options + )) as ReadResourceDirectoryResult; + } + + /** + * Reads one of `skill`'s files and verifies it against the entry: the file + * must be listed, and its size and digest must match. Throws `InvalidResult` + * on any mismatch. A `"dynamic"` skill has nothing to verify against, so its + * files are returned as read. Comparing a `SKILL.md`'s frontmatter with the + * entry needs a YAML parser and is left to the host. + */ + async read(skill: Skill, uri: string, options?: RequestOptions): Promise { + const listed = skill.resources === 'dynamic' ? undefined : skill.resources.find(resource => resource.uri === uri); + if (skill.resources !== 'dynamic' && listed === undefined) verificationFailure(uri, `not listed in ${skill.uri}`); + + const { contents } = await this.client.readResource({ uri }, options); + const content = contents.find(item => item.uri === uri) ?? verificationFailure(uri, 'not in the resources/read result'); + if (listed === undefined) return content; + + const bytes = rawBytes(content); + if (bytes.byteLength !== listed.size) verificationFailure(uri, `size ${bytes.byteLength}, entry says ${listed.size}`); + if ((await skillDigest(bytes)) !== listed.digest) verificationFailure(uri, 'digest mismatch'); + return content; + } + + #require(method: string): void { + if (this.capability === undefined) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support the ${SKILLS_EXTENSION_ID} extension (${method})` + ); + } + if (this.client.getServerCapabilities()?.resources === undefined) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server declares ${SKILLS_EXTENSION_ID} without resources (${method})`); + } + } +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8fc1de9347..61f006be8b 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -17,7 +17,8 @@ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], - "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"] + "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"], + "@modelcontextprotocol/core-internal/ext/skills": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts"] } } } diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 1a1229fedf..205518dff4 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/stdio.ts', + 'src/ext/skills/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', @@ -28,6 +29,7 @@ export default defineConfig({ 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.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'] } diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 86b79e20b9..3df50201d7 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" diff --git a/packages/core-internal/src/ext/skills/digest.ts b/packages/core-internal/src/ext/skills/digest.ts new file mode 100644 index 0000000000..bf2634203c --- /dev/null +++ b/packages/core-internal/src/ext/skills/digest.ts @@ -0,0 +1,20 @@ +import type { SkillResource } from './types'; + +const encoder = new TextEncoder(); + +/** The raw bytes of a file: UTF-8 for text. */ +export function skillFileBytes(content: string | Uint8Array): Uint8Array { + return typeof content === 'string' ? encoder.encode(content) : content; +} + +/** `sha256:{hex}` of raw bytes, the digest format of a `SkillResource`. */ +export async function skillDigest(bytes: Uint8Array): Promise { + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as Uint8Array)); + return `sha256:${Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('')}`; +} + +/** The `SkillResource` entry for a file: its URI, digest and size. */ +export async function skillResourceOf(uri: string, content: string | Uint8Array): Promise { + const bytes = skillFileBytes(content); + return { uri, digest: await skillDigest(bytes), size: bytes.byteLength }; +} 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..3cae6115ac --- /dev/null +++ b/packages/core-internal/src/ext/skills/index.ts @@ -0,0 +1,8 @@ +/** + * Wire types, zod schemas and digest helpers of the MCP Skills extension + * (`io.modelcontextprotocol/skills`, SEP-2640), shared by the server and + * client halves of the extension. + */ +export * from './digest'; +export * from './schemas'; +export * from './types'; diff --git a/packages/core-internal/src/ext/skills/schemas.ts b/packages/core-internal/src/ext/skills/schemas.ts new file mode 100644 index 0000000000..464cf2c2de --- /dev/null +++ b/packages/core-internal/src/ext/skills/schemas.ts @@ -0,0 +1,97 @@ +/* + * Zod schemas for the MCP Skills extension wire types (./types), authored + * against modelcontextprotocol/ext-skills `specification/stable/skills.mdx` + * (SEP-2640) at commit 167da6c. Adapted from typescript-sdk#2818. + * + * Objects are loose so `_meta` and fields added by later revisions pass + * through. `resultType` is not declared: the 2026-07-28 codec stamps and + * consumes it. The 512-entry / 16 MiB limits are a floor hosts must accept, + * not a ceiling, so the schemas do not enforce them. + * + * Copyright (c) Model Context Protocol contributors + */ + +import * as z from 'zod/v4'; + +import { ResourceSchema } from '../../types/schemas'; +import { SKILL_DIGEST_PATTERN, SKILL_MANIFEST_FILENAME } from './types'; + +const metaSchema = z.record(z.string(), z.unknown()); +const cacheFields = { ttlMs: z.number().int().nonnegative(), cacheScope: z.enum(['public', 'private']) }; + +/** `SkillsExtensionCapability` */ +export const skillsExtensionCapabilitySchema = z.looseObject({ directoryRead: z.boolean().optional() }); + +/** `SkillResource` */ +export const skillResourceSchema = z.looseObject({ + uri: z.string(), + digest: z.string().regex(SKILL_DIGEST_PATTERN, 'digest must be "sha256:" followed by 64 lowercase hex characters'), + size: z.number().int().nonnegative() +}); + +/** `SkillFrontmatter` */ +export const skillFrontmatterSchema = z.looseObject({ name: z.string(), description: z.string() }); + +const MANIFEST_SUFFIX = `/${SKILL_MANIFEST_FILENAME}`; + +/** + * `Skill`, with the structural rules every entry must meet: `uri` names a + * `SKILL.md`, its directory ends in `frontmatter.name`, and a static + * `resources` lists that `SKILL.md` and only files under the skill. + */ +export const skillSchema = z + .looseObject({ + uri: z.string(), + frontmatter: skillFrontmatterSchema, + resources: z.union([z.array(skillResourceSchema), z.literal('dynamic')]) + }) + .superRefine((skill, ctx) => { + if (!skill.uri.endsWith(MANIFEST_SUFFIX)) { + ctx.addIssue({ code: 'custom', path: ['uri'], message: `skill uri must name its ${SKILL_MANIFEST_FILENAME}` }); + return; + } + const root = skill.uri.slice(0, -MANIFEST_SUFFIX.length); + if (root.slice(root.lastIndexOf('/') + 1) !== skill.frontmatter.name) { + ctx.addIssue({ code: 'custom', path: ['uri'], message: 'the final skill path segment must equal frontmatter.name' }); + } + if (skill.resources === 'dynamic') return; + if (!skill.resources.some(resource => resource.uri === skill.uri)) { + ctx.addIssue({ code: 'custom', path: ['resources'], message: `resources must list the skill's ${SKILL_MANIFEST_FILENAME}` }); + } + for (const [index, resource] of skill.resources.entries()) { + if (!resource.uri.startsWith(`${root}/`)) { + ctx.addIssue({ code: 'custom', path: ['resources', index, 'uri'], message: 'resource is outside the skill directory' }); + } + } + }); + +/** `ListSkillsParams` */ +export const listSkillsParamsSchema = z.looseObject({ cursor: z.string().optional(), _meta: metaSchema.optional() }); + +/** `ListSkillsResult` */ +export const listSkillsResultSchema = z.looseObject({ + skills: z.array(skillSchema), + nextCursor: z.string().optional(), + ...cacheFields, + _meta: metaSchema.optional() +}); + +/** `GetSkillParams` */ +export const getSkillParamsSchema = z.looseObject({ uri: z.string(), _meta: metaSchema.optional() }); + +/** `GetSkillResult` */ +export const getSkillResultSchema = z.looseObject({ skill: skillSchema, ...cacheFields, _meta: metaSchema.optional() }); + +/** `ReadResourceDirectoryParams` */ +export const readResourceDirectoryParamsSchema = z.looseObject({ + uri: z.string(), + cursor: z.string().optional(), + _meta: metaSchema.optional() +}); + +/** `ReadResourceDirectoryResult` */ +export const readResourceDirectoryResultSchema = z.looseObject({ + resources: z.array(ResourceSchema), + nextCursor: z.string().optional(), + _meta: metaSchema.optional() +}); diff --git a/packages/core-internal/src/ext/skills/types.ts b/packages/core-internal/src/ext/skills/types.ts new file mode 100644 index 0000000000..e6e7e7f5bf --- /dev/null +++ b/packages/core-internal/src/ext/skills/types.ts @@ -0,0 +1,106 @@ +/* + * MCP Skills extension wire types (extension id: io.modelcontextprotocol/skills). + * + * Authored against modelcontextprotocol/ext-skills `specification/stable/skills.mdx` + * (SEP-2640) at commit 167da6c, base revision 2026-07-28. Constants and + * naming adapted from typescript-sdk#2818. + * https://github.com/modelcontextprotocol/ext-skills + * + * Copyright (c) Model Context Protocol contributors + */ + +import type { Resource, Result } from '../../types/index'; + +/** The MCP Skills extension identifier. */ +export const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; + +/** The file every skill has at its root; a skill is addressed by this file's URI. */ +export const SKILL_MANIFEST_FILENAME = 'SKILL.md'; + +/** `mimeType` of a directory resource (`resources/directory/read`). */ +export const DIRECTORY_MIME_TYPE = 'inode/directory'; + +/** Per-skill limit on `resources` entries, `SKILL.md` included. Hosts MUST accept up to this. */ +export const MAX_SKILL_RESOURCES = 512; + +/** Per-skill limit on the sum of `size` over `resources`: 16 MiB. Hosts MUST accept up to this. */ +export const MAX_SKILL_TOTAL_BYTES = 16_777_216; + +/** `sha256:` followed by 64 lowercase hex characters. */ +export const SKILL_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; + +/** Capability settings under `capabilities.extensions["io.modelcontextprotocol/skills"]`. `{}` declares support with no optional features. */ +export interface SkillsExtensionCapability { + /** The server implements `resources/directory/read`. Default `false`. */ + directoryRead?: boolean; + [key: string]: unknown; +} + +/** A file belonging to a skill, with the digest and size of its content. */ +export interface SkillResource { + /** Resource URI of the file. */ + uri: string; + /** SHA-256 digest of the file's raw bytes, `sha256:{64 lowercase hex}`. */ + digest: string; + /** Length in bytes of the file's raw content (the bytes `digest` covers). */ + size: number; +} + +/** A skill's `SKILL.md` YAML frontmatter, verbatim as JSON. `name` and `description` are always present. */ +export interface SkillFrontmatter { + name: string; + description: string; + [key: string]: unknown; +} + +/** The entry for one skill, returned by both `skills/list` and `skills/get`. */ +export interface Skill { + /** Resource URI of the skill's `SKILL.md`, readable via `resources/read`. */ + uri: string; + /** The `SKILL.md` frontmatter, verbatim. */ + frontmatter: SkillFrontmatter; + /** Every file of the skill (`SKILL.md` included), or `"dynamic"` when stable digests cannot be published. */ + resources: SkillResource[] | 'dynamic'; +} + +/** Where a cacheable result may be cached. */ +export type SkillsCacheScope = 'public' | 'private'; + +/** `skills/list` params: standard list pagination. */ +export interface ListSkillsParams { + cursor?: string; +} + +/** `skills/list` result. `ttlMs` and `cacheScope` are required, as on `resources/list`. */ +export interface ListSkillsResult extends Result { + skills: Skill[]; + nextCursor?: string; + ttlMs: number; + cacheScope: SkillsCacheScope; +} + +/** `skills/get` params. */ +export interface GetSkillParams { + /** URI of the skill's `SKILL.md`. */ + uri: string; +} + +/** `skills/get` result. `ttlMs` and `cacheScope` are required, as on `resources/read`. */ +export interface GetSkillResult extends Result { + skill: Skill; + ttlMs: number; + cacheScope: SkillsCacheScope; +} + +/** `resources/directory/read` params. */ +export interface ReadResourceDirectoryParams { + /** URI of the directory resource, no trailing slash. */ + uri: string; + cursor?: string; +} + +/** `resources/directory/read` result: the directory's direct children. Subdirectories carry `mimeType: "inode/directory"`. */ +export interface ReadResourceDirectoryResult extends Result { + resources: Resource[]; + nextCursor?: string; +} diff --git a/packages/core-internal/test/ext/skills/schemas.test.ts b/packages/core-internal/test/ext/skills/schemas.test.ts new file mode 100644 index 0000000000..c99533a35c --- /dev/null +++ b/packages/core-internal/test/ext/skills/schemas.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { MAX_SKILL_RESOURCES, skillResourceOf, skillSchema } from '../../../src/ext/skills/index'; + +const URI = 'skill://acme/billing/refunds/SKILL.md'; +const frontmatter = { name: 'refunds', description: 'Process customer refund requests', license: 'Apache-2.0' }; +const manifest = await skillResourceOf(URI, '---\nname: refunds\n---\n'); + +describe('skillSchema', () => { + it('accepts a static entry, keeping every frontmatter field', () => { + const parsed = skillSchema.parse({ uri: URI, frontmatter, resources: [manifest] }); + expect(parsed.frontmatter).toEqual(frontmatter); + }); + + it('accepts "dynamic" resources', () => { + expect(skillSchema.safeParse({ uri: URI, frontmatter, resources: 'dynamic' }).success).toBe(true); + }); + + it('does not cap resources at the spec baseline, which hosts may exceed', () => { + const extra = Array.from({ length: MAX_SKILL_RESOURCES }, (_, i) => ({ + ...manifest, + uri: `skill://acme/billing/refunds/f${i}.md` + })); + expect(skillSchema.safeParse({ uri: URI, frontmatter, resources: [manifest, ...extra] }).success).toBe(true); + }); + + it.each([ + ['a uri that does not name SKILL.md', { uri: 'skill://acme/billing/refunds', resources: [manifest] }], + ['a final path segment other than frontmatter.name', { uri: 'skill://acme/billing/refund/SKILL.md', resources: 'dynamic' }], + ['resources without the SKILL.md', { uri: URI, resources: [] }], + ['a resource outside the skill directory', { uri: URI, resources: [manifest, { ...manifest, uri: 'skill://acme/other.md' }] }], + ['a digest not in sha256:{hex} form', { uri: URI, resources: [{ ...manifest, digest: manifest.digest.toUpperCase() }] }], + ['resources missing entirely', { uri: URI }] + ])('rejects %s', (_, entry) => { + expect(skillSchema.safeParse({ frontmatter, ...entry }).success).toBe(false); + }); +}); diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 979ff15070..b3533aedc2 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -37,11 +37,11 @@ function readManifest(relativeDir: string): PackageManifest { const PUBLIC_PACKAGES: Record }> = { client: { name: '@modelcontextprotocol/client', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + exportKeys: ['.', './stdio', './ext/skills', './validators/ajv', './validators/cf-worker', './_shims'] }, server: { name: '@modelcontextprotocol/server', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + exportKeys: ['.', './stdio', './ext/skills', './validators/ajv', './validators/cf-worker', './_shims'] }, 'server-legacy': { name: '@modelcontextprotocol/server-legacy', diff --git a/packages/server/package.json b/packages/server/package.json index 652683ed0a..2144e5b6de 100644 --- a/packages/server/package.json +++ b/packages/server/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", @@ -115,6 +125,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "ext/skills": [ + "dist/ext/skills/index.d.mts" ] } }, @@ -141,6 +154,7 @@ "ajv": "catalog:runtimeShared", "ajv-formats": "catalog:runtimeShared", "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", "@modelcontextprotocol/eslint-config": "workspace:^", "@modelcontextprotocol/test-helpers": "workspace:^", diff --git a/packages/server/src/ext/skills/index.ts b/packages/server/src/ext/skills/index.ts new file mode 100644 index 0000000000..418b22b90a --- /dev/null +++ b/packages/server/src/ext/skills/index.ts @@ -0,0 +1,46 @@ +/** + * `@modelcontextprotocol/server/ext/skills` — the server side of the MCP + * Skills extension (`io.modelcontextprotocol/skills`, SEP-2640). + * + * `SkillsExtension` owns the wire: capability, `skills/list`, `skills/get` + * and `resources/directory/read`. A `SkillSource` owns the catalog. Skill + * files are ordinary resources; `skillResourceOf` computes the digest and + * size an entry lists for each. + */ + +export type { ReadResourceDirectoryPage, SkillsExtensionOptions, SkillSource } from './skillsExtension'; +export { SkillsExtension } from './skillsExtension'; +export type { + GetSkillParams, + GetSkillResult, + ListSkillsParams, + ListSkillsResult, + ReadResourceDirectoryParams, + ReadResourceDirectoryResult, + Skill, + SkillFrontmatter, + SkillResource, + SkillsCacheScope, + SkillsExtensionCapability +} from '@modelcontextprotocol/core-internal/ext/skills'; +export { + getSkillParamsSchema, + getSkillResultSchema, + listSkillsParamsSchema, + listSkillsResultSchema, + readResourceDirectoryParamsSchema, + readResourceDirectoryResultSchema, + skillFrontmatterSchema, + skillResourceSchema, + skillSchema, + skillsExtensionCapabilitySchema +} from '@modelcontextprotocol/core-internal/ext/skills'; +export { + DIRECTORY_MIME_TYPE, + MAX_SKILL_RESOURCES, + MAX_SKILL_TOTAL_BYTES, + SKILL_MANIFEST_FILENAME, + skillDigest, + skillResourceOf, + SKILLS_EXTENSION_ID +} from '@modelcontextprotocol/core-internal/ext/skills'; diff --git a/packages/server/src/ext/skills/skillsExtension.ts b/packages/server/src/ext/skills/skillsExtension.ts new file mode 100644 index 0000000000..5a2688e409 --- /dev/null +++ b/packages/server/src/ext/skills/skillsExtension.ts @@ -0,0 +1,130 @@ +/** + * `SkillsExtension` — the server side of the MCP Skills extension + * (`io.modelcontextprotocol/skills`, SEP-2640) as a {@linkcode ServerExtension}. + * It owns the wire: the capability, `skills/list`, `skills/get`, and + * `resources/directory/read` when the source can read directories. The + * skills themselves come from the {@link SkillSource} the server passes in; + * their files are ordinary resources, registered and read the usual way. + * + * ```ts + * const skills = new SkillsExtension({ + * list: () => ({ skills: [entry] }), + * get: ({ uri }) => (uri === entry.uri ? entry : undefined) + * }); + * const server = new McpServer(info, { extensions: [skills] }); + * server.registerResource('git-workflow', entry.uri, { mimeType: 'text/markdown' }, uri => ({ + * contents: [{ uri: uri.href, mimeType: 'text/markdown', text: skillMd }] + * })); + * ``` + */ + +import type { Resource, ServerContext } from '@modelcontextprotocol/core-internal'; +import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core-internal'; +import type { + GetSkillParams, + GetSkillResult, + ListSkillsParams, + ListSkillsResult, + ReadResourceDirectoryParams, + ReadResourceDirectoryResult, + Skill, + SkillsCacheScope +} from '@modelcontextprotocol/core-internal/ext/skills'; +import { + getSkillParamsSchema, + listSkillsParamsSchema, + readResourceDirectoryParamsSchema, + readResourceDirectoryResultSchema, + SKILLS_EXTENSION_ID, + skillSchema +} from '@modelcontextprotocol/core-internal/ext/skills'; + +import type { ServerExtension } from '../../server/extension'; +import type { Server } from '../../server/server'; + +/** + * Where a server's skills come from. Each method receives the request's + * params and context; pagination and any per-caller view are the source's. + */ +export interface SkillSource { + /** A page of skill entries. An empty or partial listing is allowed. */ + list( + params: ListSkillsParams, + ctx: ServerContext + ): { skills: Skill[]; nextCursor?: string } | Promise<{ skills: Skill[]; nextCursor?: string }>; + /** The entry for the skill whose `SKILL.md` is at `params.uri`, listed or not; `undefined` when none is served. */ + get(params: GetSkillParams, ctx: ServerContext): Skill | undefined | Promise; + /** The direct children of a directory resource; `undefined` when `params.uri` is not one. Implementing it advertises `directoryRead`. */ + readDirectory?( + params: ReadResourceDirectoryParams, + ctx: ServerContext + ): ReadResourceDirectoryPage | undefined | Promise; +} + +/** A page of `resources/directory/read` children. */ +export interface ReadResourceDirectoryPage { + resources: Resource[]; + nextCursor?: string; +} + +/** Options for {@link SkillsExtension}. */ +export interface SkillsExtensionOptions { + /** `ttlMs` and `cacheScope` for `skills/list` and `skills/get` results. Default `{ ttlMs: 0, cacheScope: 'private' }`. */ + cacheHint?: { ttlMs?: number; cacheScope?: SkillsCacheScope }; +} + +const invalidEntry = (error: unknown): never => { + throw new ProtocolError(ProtocolErrorCode.InternalError, `Skill source returned an invalid entry: ${String(error)}`); +}; + +const validEntry = (skill: Skill): Skill => { + const parsed = skillSchema.safeParse(skill); + return parsed.success ? (parsed.data as Skill) : invalidEntry(parsed.error); +}; + +export class SkillsExtension implements ServerExtension { + readonly id = SKILLS_EXTENSION_ID; + readonly source: SkillSource; + readonly #cache: { ttlMs: number; cacheScope: SkillsCacheScope }; + + constructor(source: SkillSource, options?: SkillsExtensionOptions) { + this.source = source; + this.#cache = { ttlMs: options?.cacheHint?.ttlMs ?? 0, cacheScope: options?.cacheHint?.cacheScope ?? 'private' }; + } + + install(server: Server): void { + const directoryRead = this.source.readDirectory !== undefined; + // Skill files are resources, so the extension requires the resources capability. + server.registerCapabilities({ resources: {}, extensions: { [SKILLS_EXTENSION_ID]: directoryRead ? { directoryRead } : {} } }); + + server.setRequestHandler('skills/list', { params: listSkillsParamsSchema }, async (params, ctx): Promise => { + const page = await this.source.list(params, ctx); + return { + skills: page.skills.map(skill => validEntry(skill)), + ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + ...this.#cache + }; + }); + + server.setRequestHandler('skills/get', { params: getSkillParamsSchema }, async (params, ctx): Promise => { + const skill = await this.source.get(params, ctx); + if (skill === undefined) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `No skill is served at ${params.uri}`); + return { skill: validEntry(skill), ...this.#cache }; + }); + + const readDirectory = this.source.readDirectory?.bind(this.source); + if (readDirectory === undefined) return; + server.setRequestHandler( + 'resources/directory/read', + { params: readResourceDirectoryParamsSchema }, + async (params, ctx): Promise => { + const page = await readDirectory(params, ctx); + if (page === undefined) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `${params.uri} is not a directory resource`); + } + const parsed = readResourceDirectoryResultSchema.safeParse(page); + return parsed.success ? (parsed.data as ReadResourceDirectoryResult) : invalidEntry(parsed.error); + } + ); + } +} diff --git a/packages/server/test/ext/skills/skills.e2e.test.ts b/packages/server/test/ext/skills/skills.e2e.test.ts new file mode 100644 index 0000000000..fd7918f1c2 --- /dev/null +++ b/packages/server/test/ext/skills/skills.e2e.test.ts @@ -0,0 +1,203 @@ +/** + * End to end through a real `Client` with `SkillsClientExtension` against + * `McpServer` with `SkillsExtension`, over the stateless `createMcpHandler`. + * Fixtures are the worked example of the SEP-2640 specification, so the + * digests and sizes asserted here are the spec's own. + */ +import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClientExtension } from '@modelcontextprotocol/client/ext/skills'; +import { describe, expect, it } from 'vitest'; + +import type { Skill, SkillSource } from '../../../src/ext/skills/index'; +import { DIRECTORY_MIME_TYPE, skillResourceOf, SKILLS_EXTENSION_ID, SkillsExtension } from '../../../src/ext/skills/index'; +import { CLIENT_CAPABILITIES_META_KEY, createMcpHandler, McpServer, PROTOCOL_VERSION_META_KEY } from '../../../src/index'; + +const SKILL_URI = 'skill://pdf-processing/SKILL.md'; +const FILES: Record = { + [SKILL_URI]: + '---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\n---\n\n# PDF processing\n\nChoose the matching template from `templates/`.\n', + 'skill://pdf-processing/templates/invoice.md': '# Invoice\n\nCustomer:\nAmount:\n', + 'skill://pdf-processing/templates/purchase-order.md': '# Purchase order\n\nSupplier:\nItems:\n' +}; + +const entry = async (): Promise => ({ + uri: SKILL_URI, + frontmatter: { name: 'pdf-processing', description: 'Extract, fill, and assemble PDF documents' }, + resources: await Promise.all(Object.entries(FILES).map(([uri, text]) => skillResourceOf(uri, text))) +}); + +const UNLISTED: Skill = { + uri: 'skill://acme/billing/refunds/SKILL.md', + frontmatter: { name: 'refunds', description: 'Process customer refund requests per company policy' }, + resources: 'dynamic' +}; + +const directorySource: Required> = { + readDirectory: ({ uri }) => + uri === 'skill://pdf-processing/templates' + ? { + resources: [ + { uri: 'skill://pdf-processing/templates/invoice.md', name: 'invoice.md', mimeType: 'text/markdown' }, + { uri: 'skill://pdf-processing/templates/regional', name: 'regional', mimeType: DIRECTORY_MIME_TYPE } + ] + } + : undefined +}; + +async function createHarness(options?: { source?: Partial; served?: Record; extension?: boolean }) { + const pdf = await entry(); + const source: SkillSource = { + list: ({ cursor }) => (cursor === undefined ? { skills: [pdf], nextCursor: 'page-2' } : { skills: [] }), + get: ({ uri }) => [pdf, UNLISTED].find(skill => skill.uri === uri), + ...directorySource, + ...options?.source + }; + const skills = new SkillsExtension(source, { cacheHint: { ttlMs: 300_000, cacheScope: 'public' } }); + const served = { ...FILES, ...options?.served }; + const createServer = () => { + const server = new McpServer( + { name: 'skills-test', version: '1.0.0' }, + { extensions: options?.extension === false ? [] : [skills] } + ); + for (const [uri, text] of Object.entries(served)) { + server.registerResource(uri, uri, { mimeType: 'text/markdown' }, () => ({ + contents: [{ uri, mimeType: 'text/markdown', text }] + })); + } + return server; + }; + const mcpHandler = createMcpHandler(createServer); + const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + fetch: (url, init) => mcpHandler.fetch(new Request(url, init)) + }); + const clientSkills = new SkillsClientExtension(); + const client = new Client({ name: 'harness', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' }, extensions: [clientSkills] }); + await client.connect(transport); + const raw = async (method: string, params: Record) => { + const response = await mcpHandler.fetch( + new Request('http://test.local/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': method + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: { [PROTOCOL_VERSION_META_KEY]: '2026-07-28', [CLIENT_CAPABILITIES_META_KEY]: {} } } + }) + }) + ); + const text = await response.text(); + const payload = response.headers.get('content-type')?.includes('text/event-stream') + ? text + .split('\n') + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trim()) + .at(-1) + : text; + return JSON.parse(payload ?? '{}') as { result?: Record; error?: { code: number; message: string } }; + }; + return { client, clientSkills, pdf, raw }; +} + +describe('SkillsExtension end to end', () => { + it('declares the extension with directoryRead, alongside resources', async () => { + const { client } = await createHarness(); + expect(client.getServerCapabilities()?.extensions).toEqual({ [SKILLS_EXTENSION_ID]: { directoryRead: true } }); + expect(client.getServerCapabilities()?.resources).toBeDefined(); + }); + + it('lists entries whose digests and sizes are the spec example values', async () => { + const { clientSkills } = await createHarness(); + const page = await clientSkills.list(); + expect(page).toMatchObject({ nextCursor: 'page-2', ttlMs: 300_000, cacheScope: 'public' }); + expect(page.skills).toEqual([ + { + uri: SKILL_URI, + frontmatter: { name: 'pdf-processing', description: 'Extract, fill, and assemble PDF documents' }, + resources: [ + { uri: SKILL_URI, digest: 'sha256:99b737495721155ece826d57521e2d66141ebdc1344a400487481ea2642ab19e', size: 151 }, + { + uri: 'skill://pdf-processing/templates/invoice.md', + digest: 'sha256:61f4ea6d2c75fde1b4977219e7e3107d491c3c26aefb6686e84d6281c088d9ee', + size: 29 + }, + { + uri: 'skill://pdf-processing/templates/purchase-order.md', + digest: 'sha256:f2ff774b1737ff3dec81c47946f9976f18a1a9f69dda0a81f22eabd95173c158', + size: 35 + } + ] + } + ]); + expect((await clientSkills.list({ cursor: 'page-2' })).skills).toEqual([]); + }); + + it('stamps resultType "complete" and the cache fields on the wire', async () => { + const { raw } = await createHarness(); + const { result } = await raw('skills/get', { uri: SKILL_URI }); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 300_000, cacheScope: 'public', skill: { uri: SKILL_URI } }); + }); + + it('gets a skill absent from the listing, and answers -32602 for one it does not serve', async () => { + const { clientSkills } = await createHarness(); + expect((await clientSkills.get(UNLISTED.uri)).skill).toEqual(UNLISTED); + await expect(clientSkills.get('skill://acme/billing/chargebacks/SKILL.md')).rejects.toMatchObject({ + code: -32602, + message: expect.stringContaining('No skill is served at skill://acme/billing/chargebacks/SKILL.md') + }); + }); + + it('answers -32603 when the source returns an entry that breaks the structural rules', async () => { + const { clientSkills, pdf } = await createHarness({ + source: { get: () => ({ ...pdf, frontmatter: { name: 'renamed', description: 'mismatch' } }) } + }); + await expect(clientSkills.get(SKILL_URI)).rejects.toMatchObject({ code: -32603 }); + }); + + it('reads a directory, and answers -32602 for a URI that is not one', async () => { + const { clientSkills } = await createHarness(); + const listing = await clientSkills.readDirectory('skill://pdf-processing/templates'); + expect(listing.resources.map(resource => [resource.name, resource.mimeType])).toEqual([ + ['invoice.md', 'text/markdown'], + ['regional', DIRECTORY_MIME_TYPE] + ]); + await expect(clientSkills.readDirectory(SKILL_URI)).rejects.toMatchObject({ code: -32602 }); + }); + + it('reads a listed file verified against the entry', async () => { + const { clientSkills, pdf } = await createHarness(); + const content = await clientSkills.read(pdf, 'skill://pdf-processing/templates/invoice.md'); + expect(content).toMatchObject({ text: '# Invoice\n\nCustomer:\nAmount:\n' }); + }); + + it('refuses a file whose bytes differ from the entry, and a file the entry does not list', async () => { + const { clientSkills, pdf } = await createHarness({ + served: { + 'skill://pdf-processing/templates/invoice.md': '# Invoice\n\nCustomer:\nAmount: 0\n', + 'skill://pdf-processing/templates/credit-note.md': '# Credit note\n' + } + }); + const invalid = { name: 'SdkError', code: SdkErrorCode.InvalidResult }; + await expect(clientSkills.read(pdf, 'skill://pdf-processing/templates/invoice.md')).rejects.toMatchObject(invalid); + await expect(clientSkills.read(pdf, 'skill://pdf-processing/templates/credit-note.md')).rejects.toMatchObject(invalid); + }); + + it('refuses directory reads when the server did not declare directoryRead', async () => { + const { clientSkills } = await createHarness({ source: { readDirectory: undefined } }); + await expect(clientSkills.readDirectory('skill://pdf-processing/templates')).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.CapabilityNotSupported + ); + }); + + it('refuses skills requests to a server that did not declare the extension', async () => { + const { clientSkills } = await createHarness({ extension: false }); + await expect(clientSkills.list()).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.CapabilityNotSupported + ); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 184ab7a899..2d9ea6db1d 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -17,7 +17,13 @@ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], - "@modelcontextprotocol/server/_shims": ["./src/shimsNode.ts"] + "@modelcontextprotocol/server/_shims": ["./src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], + "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"] } } } diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index 88004cfc06..5c67dbb8eb 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/stdio.ts', + 'src/ext/skills/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', @@ -28,6 +29,7 @@ export default defineConfig({ 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.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'] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3cf72692..634d19a14f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1797,6 +1797,9 @@ importers: '@eslint/js': specifier: catalog:devTools version: 9.39.4 + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../client '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../core-internal