From c1439c9d1f3ec5e7fe4fda249897caf3cafd9e84 Mon Sep 17 00:00:00 2001 From: tobi-oye Date: Mon, 14 Sep 2026 19:25:20 +0100 Subject: [PATCH 1/2] feat(skills): add SEP-2640 Skills extension APIs (schemas, client ops, server handlers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first phase of first-class Skills extension support behind new `/ext/skills` subpath exports. Root barrels are unchanged. - `@modelcontextprotocol/core/ext/skills` — shared, runtime-neutral schemas, inferred types, wire constants and `skillsCapabilityOf()`. - `@modelcontextprotocol/client/ext/skills` — `listSkills()`, `getSkill()` and `getSkillsCapability()`, gated on the server advertising both `io.modelcontextprotocol/skills` and `resources`. - `@modelcontextprotocol/server/ext/skills` — `installSkills()` declares the extension capability and serves `skills/list` / `skills/get` from caller-provided skill definitions. The skills result schemas deliberately carry no `resultType` member: the 2026-07-28 era codec validates that discriminator on decode and consumes it before any caller-supplied result schema runs, so a schema that re-declares it can never match (#2789). Regression tests pin this end to end. Metadata surface only — filesystem discovery, digest-verified resource reads and `resources/directory/read` follow separately. Refs #2798 Co-Authored-By: Claude Opus 5 --- .changeset/ext-skills-phase-1.md | 15 ++ packages/client/package.json | 13 ++ packages/client/src/ext/skills/index.ts | 105 ++++++++++++ .../client/test/ext/skills/skills.test.ts | 151 ++++++++++++++++++ packages/client/tsconfig.json | 1 + packages/client/tsdown.config.ts | 6 +- packages/core/package.json | 13 ++ packages/core/src/ext/skills/capability.ts | 30 ++++ packages/core/src/ext/skills/constants.ts | 43 +++++ packages/core/src/ext/skills/index.ts | 48 ++++++ packages/core/src/ext/skills/schemas.ts | 109 +++++++++++++ packages/core/src/ext/skills/types.ts | 44 +++++ packages/core/test/ext/skills/schemas.test.ts | 112 +++++++++++++ packages/core/tsdown.config.ts | 2 +- packages/server/package.json | 13 ++ packages/server/src/ext/skills/index.ts | 149 +++++++++++++++++ .../test/ext/skills/installSkills.test.ts | 142 ++++++++++++++++ packages/server/tsconfig.json | 1 + packages/server/tsdown.config.ts | 6 +- 19 files changed, 998 insertions(+), 5 deletions(-) create mode 100644 .changeset/ext-skills-phase-1.md create mode 100644 packages/client/src/ext/skills/index.ts create mode 100644 packages/client/test/ext/skills/skills.test.ts create mode 100644 packages/core/src/ext/skills/capability.ts create mode 100644 packages/core/src/ext/skills/constants.ts create mode 100644 packages/core/src/ext/skills/index.ts create mode 100644 packages/core/src/ext/skills/schemas.ts create mode 100644 packages/core/src/ext/skills/types.ts create mode 100644 packages/core/test/ext/skills/schemas.test.ts create mode 100644 packages/server/src/ext/skills/index.ts create mode 100644 packages/server/test/ext/skills/installSkills.test.ts diff --git a/.changeset/ext-skills-phase-1.md b/.changeset/ext-skills-phase-1.md new file mode 100644 index 0000000000..695c467f79 --- /dev/null +++ b/.changeset/ext-skills-phase-1.md @@ -0,0 +1,15 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/core': minor +--- + +Add first-class Skills extension (SEP-2640) APIs behind new `/ext/skills` subpath exports. Root barrels are unchanged. + +- `@modelcontextprotocol/core/ext/skills` — the shared, runtime-neutral surface: `SkillSchema`, `SkillResourceEntrySchema`, `SkillFrontmatterSchema`, `SkillResourcesSchema`, `ListSkillsRequestParamsSchema` / `ListSkillsResultSchema`, `GetSkillRequestParamsSchema` / `GetSkillResultSchema`, `SkillsCapabilitySchema` and the types inferred from them, plus the wire constants (`SKILLS_EXTENSION_ID`, `SKILLS_LIST_METHOD`, `SKILLS_GET_METHOD`, `MAX_SKILL_RESOURCES`, `MAX_SKILL_TOTAL_BYTES`, …) and `skillsCapabilityOf()`. Digests are validated as `sha256:{64 lowercase hex}`, resource lists are capped at the SEP's 512 entries, and `resources` accepts the literal `"dynamic"`. +- `@modelcontextprotocol/client/ext/skills` — `listSkills(client, params?, options?)` and `getSkill(client, params, options?)`, both gated on the server having advertised `io.modelcontextprotocol/skills` **and** `resources` (otherwise `SdkError` / `CapabilityNotSupported`), plus `getSkillsCapability(client)`. `listSkills` is the per-page call: pass a result's `nextCursor` back as `params.cursor`. +- `@modelcontextprotocol/server/ext/skills` — `installSkills(server, { skills, pageSize?, cacheHint? })` declares the extension capability and serves `skills/list` and `skills/get` from caller-provided skill definitions, answering `-32602` for a URI that names no served skill. + +The skills result schemas deliberately carry no `resultType` member: the 2026-07-28 era codec validates that discriminator on decode and consumes it before any caller-supplied result schema runs, so a schema that re-declares it can never match (#2789). Regression tests pin this end to end. + +This is the metadata surface only. Filesystem discovery, digest-verified resource reads, and `resources/directory/read` (the `directoryRead` capability flag) follow separately. diff --git a/packages/client/package.json b/packages/client/package.json index 3ebfde64d3..c4d8d66108 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/ext/skills/index.ts b/packages/client/src/ext/skills/index.ts new file mode 100644 index 0000000000..21114b9b06 --- /dev/null +++ b/packages/client/src/ext/skills/index.ts @@ -0,0 +1,105 @@ +// @modelcontextprotocol/client/ext/skills +// +// Client operations for the MCP Skills extension (SEP-2640): thin, typed +// wrappers over `skills/list` and `skills/get` that gate on capability +// negotiation and validate results against the shared schemas in +// `@modelcontextprotocol/core/ext/skills`. +// +// Adapted, with attribution, from the Apache-2.0 `@olaservo/ext-skills` +// prototype (modelcontextprotocol/ext-skills#71); the wire shapes follow the +// final SEP rather than the prototype's earlier vocabulary. + +import type { + GetSkillRequestParams, + GetSkillResult, + ListSkillsRequestParams, + ListSkillsResult, + SkillsCapability +} from '@modelcontextprotocol/core/ext/skills'; +import { + GetSkillResultSchema, + ListSkillsResultSchema, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD, + skillsCapabilityOf +} from '@modelcontextprotocol/core/ext/skills'; +import type { RequestOptions } from '@modelcontextprotocol/core-internal/public'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal/public'; + +import type { Client } from '../../client/client'; + +/** + * The Skills extension capability the connected server advertised, or + * `undefined` when it advertised none (or is not connected yet). + * + * @example + * ```ts + * if (getSkillsCapability(client)?.directoryRead) { + * // the server also serves resources/directory/read + * } + * ``` + */ +export function getSkillsCapability(client: Client): SkillsCapability | undefined { + return skillsCapabilityOf(client.getServerCapabilities()); +} + +/** + * Throws unless the server advertised both the Skills extension and the + * `resources` capability. SEP-2640 requires servers to declare `resources` + * alongside the extension, because every skill entry points at resource URIs + * the host reads through `resources/read`. + */ +function assertSkillsSupported(client: Client, method: string): void { + const capabilities = client.getServerCapabilities(); + if (skillsCapabilityOf(capabilities) === undefined) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support the ${SKILLS_EXTENSION_ID} extension (required for ${method})` + ); + } + if (!capabilities?.resources) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); + } +} + +/** + * Fetches one page of the skills a server serves. + * + * This is the per-page call: pass the previous result's `nextCursor` back as + * `params.cursor` to walk pagination, and stop when a result carries no + * `nextCursor`. A single skill entry is never split across pages. + * + * @throws {SdkError} `CapabilityNotSupported` when the server advertised + * neither the Skills extension nor `resources`. + * + * @example + * ```ts + * let cursor: string | undefined; + * const skills = []; + * do { + * const page = await listSkills(client, { cursor }); + * skills.push(...page.skills); + * cursor = page.nextCursor; + * } while (cursor !== undefined); + * ``` + */ +export async function listSkills(client: Client, params?: ListSkillsRequestParams, options?: RequestOptions): Promise { + assertSkillsSupported(client, SKILLS_LIST_METHOD); + return client.request({ method: SKILLS_LIST_METHOD, params }, ListSkillsResultSchema, options); +} + +/** + * Fetches a single skill by the URI of its `SKILL.md`. + * + * A server answers for every skill it serves, whether or not that skill + * appears in `skills/list`. A URI that names no served skill — or that is not + * a `SKILL.md` URI — comes back as a JSON-RPC `-32602` Invalid params error. + * + * @throws {SdkError} `CapabilityNotSupported` when the server advertised + * neither the Skills extension nor `resources`. + */ +export async function getSkill(client: Client, params: GetSkillRequestParams, options?: RequestOptions): Promise { + assertSkillsSupported(client, SKILLS_GET_METHOD); + return client.request({ method: SKILLS_GET_METHOD, params }, GetSkillResultSchema, options); +} diff --git a/packages/client/test/ext/skills/skills.test.ts b/packages/client/test/ext/skills/skills.test.ts new file mode 100644 index 0000000000..f8d745b3f6 --- /dev/null +++ b/packages/client/test/ext/skills/skills.test.ts @@ -0,0 +1,151 @@ +/** + * SEP-2640 Skills extension, client side: capability gating, `skills/list` + * pagination, `skills/get`, error propagation — and the typescript-sdk#2789 + * regression, where a spec-conforming `resultType: "complete"` body must parse + * rather than fail validation. + */ +import type { JSONRPCRequest, ServerCapabilities } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SKILLS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/skills'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../../src/client/client'; +import { getSkill, getSkillsCapability, listSkills } from '../../../src/ext/skills'; + +const MODERN = '2026-07-28'; + +const digest = (seed: string) => `sha256:${seed.repeat(64).slice(0, 64)}`; + +const skill = (name: string): Skill => ({ + uri: `skill://${name}/SKILL.md`, + frontmatter: { name, description: `The ${name} skill.` }, + resources: [{ uri: `skill://${name}/SKILL.md`, digest: digest('a'), size: 64 }] +}); + +const SKILLS_CAPABLE: ServerCapabilities = { + resources: {}, + extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: true } } +}; + +/** + * A scripted modern-era server. Every result body carries `resultType: + * "complete"` exactly as a spec-conforming server does — the condition + * typescript-sdk#2789 reported as unparseable. + */ +async function connectedClient( + capabilities: ServerCapabilities, + pages: Skill[][] = [[skill('alpha')]] +): Promise<{ client: Client; listParams: Array | undefined> }> { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const listParams: Array | undefined> = []; + + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + const send = (result: Record) => + void serverTx.send({ jsonrpc: '2.0', id: r.id, result: { resultType: 'complete', ...result } }); + + if (r.method === 'server/discover') { + send({ + supportedVersions: [MODERN], + capabilities, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'skills-scripted', version: '1.0.0' } } + }); + } else if (r.method === 'skills/list') { + const params = r.params as Record | undefined; + listParams.push(params); + const index = params?.['cursor'] === undefined ? 0 : Number(params['cursor']); + const next = index + 1 < pages.length ? String(index + 1) : undefined; + send({ + skills: pages[index] ?? [], + ttlMs: 60_000, + cacheScope: 'public', + ...(next !== undefined && { nextCursor: next }) + }); + } else if (r.method === 'skills/get') { + const uri = (r.params as { uri?: string } | undefined)?.uri; + const found = pages.flat().find(s => s.uri === uri); + if (found === undefined) { + void serverTx.send({ jsonrpc: '2.0', id: r.id, error: { code: -32_602, message: `Unknown skill URI: ${String(uri)}` } }); + } else { + send({ skill: found, ttlMs: 60_000, cacheScope: 'public' }); + } + } + }; + await serverTx.start(); + + const client = new Client({ name: 'skills-test-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(clientTx); + return { client, listParams }; +} + +describe('capability gating', () => { + it('reports the advertised extension capability', async () => { + const { client } = await connectedClient(SKILLS_CAPABLE); + expect(getSkillsCapability(client)).toEqual({ directoryRead: true }); + }); + + it('reports undefined when the server advertises no skills extension', async () => { + const { client } = await connectedClient({ resources: {} }); + expect(getSkillsCapability(client)).toBeUndefined(); + }); + + it('refuses skills/list and skills/get when the extension is not advertised', async () => { + const { client } = await connectedClient({ resources: {} }); + await expect(listSkills(client)).rejects.toThrow(new RegExp(SKILLS_EXTENSION_ID)); + await expect(getSkill(client, { uri: 'skill://alpha/SKILL.md' })).rejects.toThrow(new RegExp(SKILLS_EXTENSION_ID)); + }); + + it('refuses when the extension is advertised without the required resources capability', async () => { + const { client } = await connectedClient({ extensions: { [SKILLS_EXTENSION_ID]: {} } }); + await expect(listSkills(client)).rejects.toThrow(/does not support resources/); + }); +}); + +describe('skills/list', () => { + // typescript-sdk#2789: the era codec validates `resultType` on decode and + // consumes it, so a result schema must not re-declare it. These calls go + // through the real decode path against a body that carries it. + it('parses a spec-conforming resultType:"complete" body (typescript-sdk#2789)', async () => { + const { client } = await connectedClient(SKILLS_CAPABLE); + const result = await listSkills(client); + expect(result.skills.map(s => s.frontmatter.name)).toEqual(['alpha']); + expect(result.ttlMs).toBe(60_000); + expect(result.cacheScope).toBe('public'); + expect('resultType' in result).toBe(false); + }); + + it('round-trips a pagination cursor and stops when nextCursor is absent', async () => { + const { client, listParams } = await connectedClient(SKILLS_CAPABLE, [[skill('alpha')], [skill('beta')]]); + + const first = await listSkills(client); + expect(first.skills.map(s => s.frontmatter.name)).toEqual(['alpha']); + expect(first.nextCursor).toBe('1'); + + const second = await listSkills(client, { cursor: first.nextCursor }); + expect(second.skills.map(s => s.frontmatter.name)).toEqual(['beta']); + expect(second.nextCursor).toBeUndefined(); + + expect(listParams[1]).toMatchObject({ cursor: '1' }); + }); + + it('rejects a server result that violates the skill schema', async () => { + const bad = [{ ...skill('alpha'), resources: [{ uri: 'skill://alpha/SKILL.md', digest: 'not-a-digest', size: 1 }] }] as Skill[]; + const { client } = await connectedClient(SKILLS_CAPABLE, [bad]); + await expect(listSkills(client)).rejects.toThrow(/Invalid result for skills\/list/); + }); +}); + +describe('skills/get', () => { + it('fetches a skill by its SKILL.md URI', async () => { + const { client } = await connectedClient(SKILLS_CAPABLE); + const { skill: found } = await getSkill(client, { uri: 'skill://alpha/SKILL.md' }); + expect(found.frontmatter.description).toBe('The alpha skill.'); + }); + + it('surfaces the server -32602 for an unknown skill URI', async () => { + const { client } = await connectedClient(SKILLS_CAPABLE); + await expect(getSkill(client, { uri: 'skill://nope/SKILL.md' })).rejects.toThrow(/Unknown skill URI/); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8fc1de9347..86714b5a03 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/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/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 45e0d7a28e..9ec86e1ff3 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,6 +27,7 @@ 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/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], @@ -37,5 +39,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/package.json b/packages/core/package.json index 0e02ff8b1e..164d9e3515 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,12 +40,25 @@ "types": "./dist/internal.d.cts", "default": "./dist/internal.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" + } } }, "typesVersions": { "*": { "internal": [ "dist/internal.d.mts" + ], + "ext/skills": [ + "dist/ext/skills/index.d.mts" ] } }, diff --git a/packages/core/src/ext/skills/capability.ts b/packages/core/src/ext/skills/capability.ts new file mode 100644 index 0000000000..0019d4eed8 --- /dev/null +++ b/packages/core/src/ext/skills/capability.ts @@ -0,0 +1,30 @@ +import { SKILLS_EXTENSION_ID } from './constants'; +import { SkillsCapabilitySchema } from './schemas'; +import type { SkillsCapability } from './types'; + +/** + * The shape this module reads capabilities out of. Declared structurally so + * the helper stays in the runtime-neutral core package: the nominal + * `ServerCapabilities` type lives downstream, and every value of it satisfies + * this. + */ +export interface CapabilitiesWithExtensions { + extensions?: 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..04fe346b00 --- /dev/null +++ b/packages/core/src/ext/skills/constants.ts @@ -0,0 +1,43 @@ +/** + * Wire constants for the MCP Skills extension (SEP-2640). + * + * Runtime-neutral: this module and its siblings import nothing beyond `zod/v4` + * and the core schema modules, so the extension stays consumable from browser + * and Cloudflare Workers bundles. + */ + +/** + * The reverse-DNS identifier under which a server advertises the Skills + * extension in `ServerCapabilities.extensions`. + */ +export const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; + +/** Request method that enumerates the skills a server serves. */ +export const SKILLS_LIST_METHOD = 'skills/list'; + +/** Request method that fetches a single skill entry by its `SKILL.md` URI. */ +export const SKILLS_GET_METHOD = 'skills/get'; + +/** The canonical URI scheme for skills. Servers MAY serve skills under other schemes. */ +export const SKILL_URI_SCHEME = 'skill:'; + +/** The manifest filename that terminates every `SKILL.md` URI. */ +export const SKILL_MANIFEST_FILENAME = 'SKILL.md'; + +/** + * Maximum number of resource entries in a single skill (SEP-2640 normative + * limit). Hosts MUST support up to this many; servers SHOULD NOT exceed it. + */ +export const MAX_SKILL_RESOURCES = 512; + +/** + * Maximum total byte size of a single skill's files: 16 MiB (SEP-2640 + * normative limit). Checkable from a skill entry before any file is fetched. + */ +export const MAX_SKILL_TOTAL_BYTES = 16_777_216; + +/** + * The digest format required on every skill resource entry: lowercase + * `sha256:` followed by 64 hex characters. + */ +export const SKILL_DIGEST_PATTERN = /^sha256:[0-9a-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..0bad5a3a5a --- /dev/null +++ b/packages/core/src/ext/skills/index.ts @@ -0,0 +1,48 @@ +// @modelcontextprotocol/core/ext/skills +// +// Shared surface for the MCP Skills extension (SEP-2640): Zod schemas, the +// TypeScript types inferred from them, wire constants, and the runtime-neutral +// capability helper both roles use. +// +// Runtime-neutral by construction — `zod/v4` and the core schema modules are +// the only imports, so this subpath is safe in browser and Workers bundles. +// The client operations live at `@modelcontextprotocol/client/ext/skills` and +// the server handlers at `@modelcontextprotocol/server/ext/skills`. + +export type { CapabilitiesWithExtensions } from './capability'; +export { skillsCapabilityOf } from './capability'; +export { + MAX_SKILL_RESOURCES, + MAX_SKILL_TOTAL_BYTES, + SKILL_DIGEST_PATTERN, + SKILL_MANIFEST_FILENAME, + SKILL_URI_SCHEME, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD +} from './constants'; +export { + GetSkillRequestParamsSchema, + GetSkillResultSchema, + ListSkillsRequestParamsSchema, + ListSkillsResultSchema, + SkillCacheScopeSchema, + SkillDigestSchema, + SkillFrontmatterSchema, + SkillResourceEntrySchema, + SkillResourcesSchema, + SkillsCapabilitySchema, + SkillSchema +} from './schemas'; +export type { + GetSkillRequestParams, + GetSkillResult, + ListSkillsRequestParams, + ListSkillsResult, + 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..6e8a04b37c --- /dev/null +++ b/packages/core/src/ext/skills/schemas.ts @@ -0,0 +1,109 @@ +import * as z from 'zod/v4'; + +import { BaseRequestParamsSchema, PaginatedResultSchema, ResultSchema } from '../../schemas'; +import { MAX_SKILL_RESOURCES, SKILL_DIGEST_PATTERN } from './constants'; + +/** + * Zod schemas for the MCP Skills extension (SEP-2640). + * + * Adapted, with attribution, from the Apache-2.0 `@olaservo/ext-skills` + * prototype (modelcontextprotocol/ext-skills#71); the shapes here follow the + * final SEP rather than the prototype's earlier vocabulary. + * + * Note on `resultType`: the result schemas below extend the neutral + * {@linkcode ResultSchema} / {@linkcode PaginatedResultSchema} and therefore carry + * NO `resultType` member. `resultType` is wire-only vocabulary owned by the + * 2026-07-28 era codec, which validates it on decode and consumes it before a + * caller-supplied result schema ever sees the value. A result schema that + * re-declares it can never match (typescript-sdk#2789). + */ + +/** + * A SHA-256 content digest over a skill file's raw bytes, formatted + * `sha256:{64 lowercase hex}`. + */ +export const SkillDigestSchema = z.string().regex(SKILL_DIGEST_PATTERN, 'digest must be formatted "sha256:{64 lowercase hex chars}"'); + +/** One file belonging to a skill, with the material a host needs to verify it. */ +export const SkillResourceEntrySchema = z.object({ + /** Resource URI of the file. */ + uri: z.string(), + /** SHA-256 digest of the file's raw bytes. */ + digest: SkillDigestSchema, + /** The file's length in bytes. */ + size: z.number().int().nonnegative() +}); + +/** + * The YAML frontmatter of a skill's `SKILL.md`, converted to JSON verbatim. + * `name` and `description` are the required minimum; any further keys the + * author wrote are preserved. + */ +export const SkillFrontmatterSchema = z.looseObject({ + name: z.string(), + description: z.string() +}); + +/** + * A skill's file set: either the complete list of entries, or the literal + * `"dynamic"` for servers that generate content per request and therefore + * cannot publish digests up front. + */ +export const SkillResourcesSchema = z.union([z.array(SkillResourceEntrySchema).max(MAX_SKILL_RESOURCES), z.literal('dynamic')]); + +/** A single skill served by an MCP server. */ +export const SkillSchema = z.object({ + /** Resource URI of the skill's `SKILL.md`. */ + uri: z.string(), + /** The manifest's frontmatter, verbatim. */ + frontmatter: SkillFrontmatterSchema, + /** The skill's files, or `"dynamic"`. */ + resources: SkillResourcesSchema +}); + +/** Whether a result may be cached by shared caches (`public`) or only by the requesting client (`private`). */ +export const SkillCacheScopeSchema = z.enum(['public', 'private']); + +/** + * Cache freshness hints carried on skills results from protocol revision + * 2026-07-28 onward. Optional so that a single schema serves both eras. + */ +const skillCacheHintShape = { + /** Milliseconds after which the result should be refreshed. */ + ttlMs: z.number().int().nonnegative().optional(), + /** Cache scope for the result. */ + cacheScope: SkillCacheScopeSchema.optional() +}; + +/** Result of `skills/list`. */ +export const ListSkillsResultSchema = PaginatedResultSchema.extend({ + skills: z.array(SkillSchema), + ...skillCacheHintShape +}); + +/** Params for `skills/get`. */ +export const GetSkillRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** The `SKILL.md` URI of the skill to fetch. */ + uri: z.string() +}); + +/** Result of `skills/get`. */ +export const GetSkillResultSchema = ResultSchema.extend({ + skill: SkillSchema, + ...skillCacheHintShape +}); + +/** + * The value a server publishes at + * `capabilities.extensions["io.modelcontextprotocol/skills"]`. + * + * Loose so that capability fields added by later revisions of the extension + * survive a round trip instead of being stripped. + */ +export const SkillsCapabilitySchema = z.looseObject({ + /** Whether the server also serves `resources/directory/read`. Defaults to `false`. */ + directoryRead: z.boolean().optional() +}); + +/** Params for `skills/list` — standard MCP list pagination, no extra fields. */ +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..77c2c9953f --- /dev/null +++ b/packages/core/src/ext/skills/types.ts @@ -0,0 +1,44 @@ +import type * as z from 'zod/v4'; + +import type { + GetSkillRequestParamsSchema, + GetSkillResultSchema, + ListSkillsRequestParamsSchema, + ListSkillsResultSchema, + SkillCacheScopeSchema, + SkillFrontmatterSchema, + SkillResourceEntrySchema, + SkillResourcesSchema, + SkillsCapabilitySchema, + SkillSchema +} from './schemas'; + +/** One file belonging to a skill, with the material a host needs to verify it. */ +export type SkillResourceEntry = z.infer; + +/** The YAML frontmatter of a skill's `SKILL.md`, converted to JSON verbatim. */ +export type SkillFrontmatter = z.infer; + +/** A skill's file set: the complete list of entries, or the literal `"dynamic"`. */ +export type SkillResources = z.infer; + +/** A single skill served by an MCP server. */ +export type Skill = z.infer; + +/** Whether a result may be cached by shared caches (`public`) or only by the requesting client (`private`). */ +export type SkillCacheScope = z.infer; + +/** Params for `skills/list`. */ +export type ListSkillsRequestParams = z.infer; + +/** Result of `skills/list`. */ +export type ListSkillsResult = z.infer; + +/** Params for `skills/get`. */ +export type GetSkillRequestParams = z.infer; + +/** Result of `skills/get`. */ +export type GetSkillResult = z.infer; + +/** The value published 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..bfceb9be5c --- /dev/null +++ b/packages/core/test/ext/skills/schemas.test.ts @@ -0,0 +1,112 @@ +/** + * SEP-2640 Skills extension schemas: the validation the wire shapes promise, + * and the `resultType` posture that typescript-sdk#2789 turns on. + */ +import { describe, expect, it } from 'vitest'; + +import { + GetSkillResultSchema, + ListSkillsRequestParamsSchema, + ListSkillsResultSchema, + MAX_SKILL_RESOURCES, + SKILLS_EXTENSION_ID, + SkillFrontmatterSchema, + SkillResourceEntrySchema, + SkillSchema, + skillsCapabilityOf +} from '../../../src/ext/skills'; + +const DIGEST = `sha256:${'a'.repeat(64)}`; + +const SKILL = { + uri: 'skill://git-workflow/SKILL.md', + frontmatter: { name: 'git-workflow', description: 'Branching conventions.' }, + resources: [{ uri: 'skill://git-workflow/SKILL.md', digest: DIGEST, size: 128 }] +}; + +describe('skill resource entries', () => { + it('accepts a well-formed sha256 digest', () => { + expect(SkillResourceEntrySchema.safeParse({ uri: 'skill://s/SKILL.md', digest: DIGEST, size: 0 }).success).toBe(true); + }); + + it.each([ + ['no algorithm prefix', 'a'.repeat(64)], + ['uppercase hex', `sha256:${'A'.repeat(64)}`], + ['too short', `sha256:${'a'.repeat(63)}`], + ['wrong algorithm', `sha512:${'a'.repeat(64)}`] + ])('rejects a digest with %s', (_label, digest) => { + expect(SkillResourceEntrySchema.safeParse({ uri: 'skill://s/SKILL.md', digest, size: 1 }).success).toBe(false); + }); + + it('requires size to be a non-negative integer', () => { + expect(SkillResourceEntrySchema.safeParse({ uri: 'u', digest: DIGEST, size: -1 }).success).toBe(false); + expect(SkillResourceEntrySchema.safeParse({ uri: 'u', digest: DIGEST, size: 1.5 }).success).toBe(false); + }); +}); + +describe('skill entries', () => { + it('requires name and description in frontmatter and preserves any further keys verbatim', () => { + expect(SkillFrontmatterSchema.safeParse({ name: 'n' }).success).toBe(false); + const parsed = SkillFrontmatterSchema.parse({ name: 'n', description: 'd', 'allowed-tools': ['Bash'] }); + expect(parsed['allowed-tools']).toEqual(['Bash']); + }); + + it('accepts the literal "dynamic" in place of a resource list', () => { + expect(SkillSchema.safeParse({ ...SKILL, resources: 'dynamic' }).success).toBe(true); + expect(SkillSchema.safeParse({ ...SKILL, resources: 'whenever' }).success).toBe(false); + }); + + it(`caps a skill at ${MAX_SKILL_RESOURCES} resource entries`, () => { + const entry = { uri: 'skill://s/f.md', digest: DIGEST, size: 1 }; + expect(SkillSchema.safeParse({ ...SKILL, resources: Array(MAX_SKILL_RESOURCES).fill(entry) }).success).toBe(true); + expect(SkillSchema.safeParse({ ...SKILL, resources: Array(MAX_SKILL_RESOURCES + 1).fill(entry) }).success).toBe(false); + }); +}); + +describe('results carry no resultType slot (typescript-sdk#2789)', () => { + // The 2026-07-28 era codec validates `resultType` on decode and CONSUMES it + // before any caller-supplied result schema runs. A schema that re-declares + // it can therefore never match. These pin that the skills results parse a + // body with no `resultType` at all. + it('parses a skills/list body with no resultType', () => { + const parsed = ListSkillsResultSchema.parse({ skills: [SKILL], ttlMs: 60_000, cacheScope: 'public' }); + expect(parsed.skills).toHaveLength(1); + expect(parsed.nextCursor).toBeUndefined(); + }); + + it('parses a skills/get body with no resultType', () => { + expect(GetSkillResultSchema.parse({ skill: SKILL }).skill.uri).toBe(SKILL.uri); + }); + + it('rejects an unknown cacheScope rather than passing it through', () => { + expect(ListSkillsResultSchema.safeParse({ skills: [], cacheScope: 'shared' }).success).toBe(false); + }); +}); + +describe('pagination params', () => { + it('accepts an absent cursor and a string cursor', () => { + expect(ListSkillsRequestParamsSchema.safeParse({}).success).toBe(true); + expect(ListSkillsRequestParamsSchema.parse({ cursor: '2' }).cursor).toBe('2'); + expect(ListSkillsRequestParamsSchema.safeParse({ cursor: 2 }).success).toBe(false); + }); +}); + +describe('capability negotiation', () => { + it('reads the extension out of advertised capabilities', () => { + expect(skillsCapabilityOf({ extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: true } } })).toEqual({ directoryRead: true }); + expect(skillsCapabilityOf({ extensions: { [SKILLS_EXTENSION_ID]: {} } })).toEqual({}); + }); + + it('returns undefined when the extension is absent, and for a malformed value', () => { + expect(skillsCapabilityOf(undefined)).toBeUndefined(); + expect(skillsCapabilityOf({ extensions: {} })).toBeUndefined(); + expect(skillsCapabilityOf({ extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: 'yes' } } })).toBeUndefined(); + }); + + it('preserves capability fields added by later revisions', () => { + expect(skillsCapabilityOf({ extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: false, future: 1 } } })).toEqual({ + directoryRead: false, + future: 1 + }); + }); +}); 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/server/package.json b/packages/server/package.json index f481e019e7..239ae9a2a5 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", @@ -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/server/src/ext/skills/index.ts b/packages/server/src/ext/skills/index.ts new file mode 100644 index 0000000000..3276a944cc --- /dev/null +++ b/packages/server/src/ext/skills/index.ts @@ -0,0 +1,149 @@ +// @modelcontextprotocol/server/ext/skills +// +// Server side of the MCP Skills extension (SEP-2640): declares the +// `io.modelcontextprotocol/skills` capability and serves `skills/list` and +// `skills/get` from a caller-provided set of skill definitions. +// +// Adapted, with attribution, from the Apache-2.0 `@olaservo/ext-skills` +// prototype (modelcontextprotocol/ext-skills#71); the wire shapes follow the +// final SEP rather than the prototype's earlier vocabulary. +// +// Scope: this phase serves skill *metadata* only. The files a skill entry +// points at stay ordinary MCP resources, registered and read the usual way — +// filesystem discovery and digest-verified reads are separate concerns. + +import type { ListSkillsResult, Skill, SkillCacheScope } from '@modelcontextprotocol/core/ext/skills'; +import { + GetSkillRequestParamsSchema, + GetSkillResultSchema, + ListSkillsRequestParamsSchema, + ListSkillsResultSchema, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD +} from '@modelcontextprotocol/core/ext/skills'; +import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core-internal/public'; + +import type { Server } from '../../server/server'; + +/** + * Cache freshness hints stamped onto skills results. + * + * SEP-2640 makes `ttlMs` / `cacheScope` part of the skills results from + * protocol revision 2026-07-28 onward. The SDK's own cache-fill seam covers a + * closed set of six core operations and deliberately does not extend to + * extension methods, so the extension supplies its own values here. + */ +export interface SkillsCacheHint { + /** Milliseconds after which a client should refresh. Defaults to `0`. */ + ttlMs?: number; + /** Cache scope for the result. Defaults to `'private'`. */ + cacheScope?: SkillCacheScope; +} + +/** Options for {@linkcode installSkills}. */ +export interface InstallSkillsOptions { + /** + * The skills this server serves, in the order `skills/list` should return + * them. Every entry's `uri` must be unique — it is the key `skills/get` + * resolves against. + */ + skills: readonly Skill[]; + + /** + * How many skills a single `skills/list` page may carry. When set, results + * beyond the page are reached through `nextCursor`. Unset means one page. + */ + pageSize?: number; + + /** Cache hints to stamp onto `skills/list` and `skills/get` results. */ + cacheHint?: SkillsCacheHint; +} + +/** Handle returned by {@linkcode installSkills}. */ +export interface SkillsRegistration { + /** The skills being served, keyed by `SKILL.md` URI. */ + readonly skills: ReadonlyMap; +} + +/** Decodes an opaque `skills/list` cursor into the offset it encodes. */ +function offsetFromCursor(cursor: string | undefined, total: number): number { + if (cursor === undefined) { + return 0; + } + const offset = Number(cursor); + if (!Number.isSafeInteger(offset) || offset < 0 || offset > total) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid cursor: ${cursor}`); + } + return offset; +} + +/** + * Declares the `io.modelcontextprotocol/skills` capability and registers + * handlers for `skills/list` and `skills/get`. + * + * Must be called before the server connects to a transport — capabilities + * cannot be registered afterwards. The caller is responsible for registering + * the skills' files as resources (and for the `resources` capability SEP-2640 + * requires alongside the extension). + * + * @example + * ```ts + * const mcpServer = new McpServer({ name: 'skills-demo', version: '1.0.0' }, { capabilities: { resources: {} } }); + * + * installSkills(mcpServer.server, { + * skills: [ + * { + * uri: 'skill://git-workflow/SKILL.md', + * frontmatter: { name: 'git-workflow', description: 'Conventions for branching and review.' }, + * resources: [{ uri: 'skill://git-workflow/SKILL.md', digest: `sha256:${sha}`, size: bytes.byteLength }] + * } + * ] + * }); + * ``` + */ +export function installSkills(server: Server, options: InstallSkillsOptions): SkillsRegistration { + const { skills, pageSize, cacheHint } = options; + + if (pageSize !== undefined && (!Number.isSafeInteger(pageSize) || pageSize < 1)) { + throw new RangeError(`installSkills: pageSize must be a positive integer (got ${String(pageSize)})`); + } + + const byUri = new Map(); + for (const skill of skills) { + if (byUri.has(skill.uri)) { + throw new Error(`installSkills: duplicate skill URI ${skill.uri}`); + } + byUri.set(skill.uri, skill); + } + const ordered = [...byUri.values()]; + + server.registerCapabilities({ extensions: { [SKILLS_EXTENSION_ID]: {} } }); + + const hints = cacheHint === undefined ? {} : { ttlMs: cacheHint.ttlMs ?? 0, cacheScope: cacheHint.cacheScope ?? 'private' }; + + server.setRequestHandler( + SKILLS_LIST_METHOD, + { params: ListSkillsRequestParamsSchema, result: ListSkillsResultSchema }, + (params): ListSkillsResult => { + const start = offsetFromCursor(params?.cursor, ordered.length); + const end = pageSize === undefined ? ordered.length : Math.min(start + pageSize, ordered.length); + const page = ordered.slice(start, end); + return { + skills: page, + ...(end < ordered.length ? { nextCursor: String(end) } : {}), + ...hints + }; + } + ); + + server.setRequestHandler(SKILLS_GET_METHOD, { params: GetSkillRequestParamsSchema, result: GetSkillResultSchema }, params => { + const skill = byUri.get(params.uri); + if (skill === undefined) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown skill URI: ${params.uri}`); + } + return { skill, ...hints }; + }); + + return { skills: byUri }; +} diff --git a/packages/server/test/ext/skills/installSkills.test.ts b/packages/server/test/ext/skills/installSkills.test.ts new file mode 100644 index 0000000000..abdd4b9aa3 --- /dev/null +++ b/packages/server/test/ext/skills/installSkills.test.ts @@ -0,0 +1,142 @@ +/** + * SEP-2640 Skills extension, server side: capability advertisement, the + * `skills/list` / `skills/get` handlers, pagination, the required -32602 on an + * unknown skill URI, and the 2026-07-28 wire shape (`resultType` stamped by + * the era codec, cache hints supplied by the extension). + */ +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 type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SKILLS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/skills'; +import { describe, expect, it } from 'vitest'; + +import { installSkills } from '../../../src/ext/skills'; +import { invoke } from '../../../src/server/invoke'; +import { Server } from '../../../src/server/server'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'skills-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const request = (method: string, params: Record = {}): JSONRPCRequest => + ({ jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: ENVELOPE } }) as JSONRPCRequest; + +const digest = (seed: string) => `sha256:${seed.repeat(64).slice(0, 64)}`; + +const skill = (name: string): Skill => ({ + uri: `skill://${name}/SKILL.md`, + frontmatter: { name, description: `The ${name} skill.` }, + resources: [{ uri: `skill://${name}/SKILL.md`, digest: digest('a'), size: 64 }] +}); + +function serverWith(skills: readonly Skill[], options: { pageSize?: number; cacheHint?: { ttlMs?: number } } = {}): Server { + const server = new Server({ name: 'skills-server', version: '1.0.0' }, { capabilities: { resources: {} } }); + installSkills(server, { skills, ...options }); + return server; +} + +async function call(server: Server, message: JSONRPCRequest): Promise> { + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const response = await invoke(server, message, { classification: MODERN }); + const body = (await response.json()) as Record; + return body; +} + +const resultOf = (body: Record) => body['result'] as Record; +const errorOf = (body: Record) => body['error'] as { code: number; message: string }; + +describe('capability negotiation', () => { + it('advertises the skills extension alongside the caller-declared resources capability', () => { + const server = serverWith([skill('alpha')]); + expect(server.getCapabilities()).toMatchObject({ + resources: {}, + extensions: { [SKILLS_EXTENSION_ID]: {} } + }); + }); + + it('rejects a duplicate skill URI at install time', () => { + const server = new Server({ name: 's', version: '1' }, { capabilities: { resources: {} } }); + expect(() => installSkills(server, { skills: [skill('alpha'), skill('alpha')] })).toThrowError(/duplicate skill URI/); + }); + + it('rejects a non-positive pageSize at install time', () => { + const server = new Server({ name: 's', version: '1' }, { capabilities: { resources: {} } }); + expect(() => installSkills(server, { skills: [], pageSize: 0 })).toThrowError(RangeError); + }); +}); + +describe('skills/list', () => { + it('returns every skill in one page when no pageSize is set', async () => { + const result = resultOf(await call(serverWith([skill('alpha'), skill('beta')]), request('skills/list'))); + expect((result['skills'] as Skill[]).map(s => s.frontmatter.name)).toEqual(['alpha', 'beta']); + expect(result['nextCursor']).toBeUndefined(); + }); + + it('paginates and never splits a skill entry across pages', async () => { + const server = serverWith([skill('alpha'), skill('beta'), skill('gamma')], { pageSize: 2 }); + const first = resultOf(await call(server, request('skills/list'))); + expect((first['skills'] as Skill[]).map(s => s.frontmatter.name)).toEqual(['alpha', 'beta']); + expect(first['nextCursor']).toBe('2'); + + const second = resultOf(await call(server, request('skills/list', { cursor: first['nextCursor'] as string }))); + expect((second['skills'] as Skill[]).map(s => s.frontmatter.name)).toEqual(['gamma']); + expect(second['nextCursor']).toBeUndefined(); + }); + + it('rejects an out-of-range or non-numeric cursor with -32602', async () => { + const server = serverWith([skill('alpha')], { pageSize: 1 }); + expect(errorOf(await call(server, request('skills/list', { cursor: '99' }))).code).toBe(-32_602); + expect(errorOf(await call(server, request('skills/list', { cursor: 'nope' }))).code).toBe(-32_602); + }); + + it('carries the era-stamped resultType and the extension-supplied cache hints', async () => { + const server = serverWith([skill('alpha')], { cacheHint: { ttlMs: 60_000 } }); + const result = resultOf(await call(server, request('skills/list'))); + expect(result['resultType']).toBe('complete'); + expect(result['ttlMs']).toBe(60_000); + expect(result['cacheScope']).toBe('private'); + }); + + it('omits the cache fields when no hint is configured', async () => { + const result = resultOf(await call(serverWith([skill('alpha')]), request('skills/list'))); + expect('ttlMs' in result).toBe(false); + expect('cacheScope' in result).toBe(false); + }); +}); + +describe('skills/get', () => { + it('returns the skill named by its SKILL.md URI', async () => { + const result = resultOf(await call(serverWith([skill('alpha')]), request('skills/get', { uri: 'skill://alpha/SKILL.md' }))); + expect((result['skill'] as Skill).frontmatter.name).toBe('alpha'); + }); + + it('answers for a served skill that skills/list does not page in', async () => { + const server = serverWith([skill('alpha'), skill('beta')], { pageSize: 1 }); + const result = resultOf(await call(server, request('skills/get', { uri: 'skill://beta/SKILL.md' }))); + expect((result['skill'] as Skill).frontmatter.name).toBe('beta'); + }); + + it('rejects an unknown skill URI with -32602', async () => { + const error = errorOf(await call(serverWith([skill('alpha')]), request('skills/get', { uri: 'skill://nope/SKILL.md' }))); + expect(error.code).toBe(-32_602); + expect(error.message).toContain('skill://nope/SKILL.md'); + }); + + it('rejects a skill root URI that is not the SKILL.md URI with -32602', async () => { + expect(errorOf(await call(serverWith([skill('alpha')]), request('skills/get', { uri: 'skill://alpha' }))).code).toBe(-32_602); + }); + + it('rejects missing params with -32602', async () => { + expect(errorOf(await call(serverWith([skill('alpha')]), request('skills/get'))).code).toBe(-32_602); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 184ab7a899..ab272e9708 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/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/tsdown.config.ts b/packages/server/tsdown.config.ts index 88004cfc06..e1163abf6f 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/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,6 +27,7 @@ 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/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], @@ -37,5 +39,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'] }); From b0091060d73d08211c6766990aa15656d4e03271 Mon Sep 17 00:00:00 2001 From: tobi-oye Date: Mon, 14 Sep 2026 19:53:28 +0100 Subject: [PATCH 2/2] test(core-internal): pin ./ext/skills in the public package topology `packageTopologyPins` asserts each published package's export-map keys exactly, so the new `./ext/skills` subpath on core, client and server trips it by design. Per docs/behavior-surface-pins.md the pin is updated in the same PR rather than loosened, with a note on each entry recording why the subpath is public and why it stays off the root barrel. No behavior change: this updates the expectation only. Co-Authored-By: Claude Opus 5 --- .../core-internal/test/packageTopologyPins.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 979ff15070..925b22dfe2 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -37,11 +37,14 @@ 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. + exportKeys: ['.', './stdio', './ext/skills', './validators/ajv', './validators/cf-worker', './_shims'] }, 'server-legacy': { name: '@modelcontextprotocol/server-legacy', @@ -56,7 +59,10 @@ const PUBLIC_PACKAGES: Record