From c1439c9d1f3ec5e7fe4fda249897caf3cafd9e84 Mon Sep 17 00:00:00 2001 From: tobi-oye Date: Mon, 14 Sep 2026 19:25:20 +0100 Subject: [PATCH 01/45] 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 02/45] 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 Date: Wed, 23 Sep 2026 00:27:59 +0100 Subject: [PATCH 03/45] fix(skills): default cache hints only on modern protocol revisions --- packages/server/src/ext/skills/index.ts | 16 ++++-- .../test/ext/skills/installSkills.test.ts | 54 +++++++++++++------ 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/server/src/ext/skills/index.ts b/packages/server/src/ext/skills/index.ts index 3276a944cc..d8eb2fbbed 100644 --- a/packages/server/src/ext/skills/index.ts +++ b/packages/server/src/ext/skills/index.ts @@ -22,6 +22,7 @@ import { SKILLS_GET_METHOD, SKILLS_LIST_METHOD } from '@modelcontextprotocol/core/ext/skills'; +import { isModernProtocolVersion } from '@modelcontextprotocol/core-internal'; import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core-internal/public'; import type { Server } from '../../server/server'; @@ -56,7 +57,11 @@ export interface InstallSkillsOptions { */ pageSize?: number; - /** Cache hints to stamp onto `skills/list` and `skills/get` results. */ + /** + * Cache hints for `skills/list` and `skills/get` on protocol revision + * 2026-07-28 onward. Defaults to `ttlMs: 0` and `cacheScope: 'private'`; + * omitted entirely from responses on older protocol revisions. + */ cacheHint?: SkillsCacheHint; } @@ -120,7 +125,10 @@ export function installSkills(server: Server, options: InstallSkillsOptions): Sk server.registerCapabilities({ extensions: { [SKILLS_EXTENSION_ID]: {} } }); - const hints = cacheHint === undefined ? {} : { ttlMs: cacheHint.ttlMs ?? 0, cacheScope: cacheHint.cacheScope ?? 'private' }; + const hints = { ttlMs: cacheHint?.ttlMs ?? 0, cacheScope: cacheHint?.cacheScope ?? 'private' }; + // Resolve at request time: installation precedes protocol negotiation. + // Use the instance's negotiated era, not caller-supplied request metadata. + const hintsForRequest = () => (isModernProtocolVersion(server.getNegotiatedProtocolVersion() ?? '') ? hints : {}); server.setRequestHandler( SKILLS_LIST_METHOD, @@ -132,7 +140,7 @@ export function installSkills(server: Server, options: InstallSkillsOptions): Sk return { skills: page, ...(end < ordered.length ? { nextCursor: String(end) } : {}), - ...hints + ...hintsForRequest() }; } ); @@ -142,7 +150,7 @@ export function installSkills(server: Server, options: InstallSkillsOptions): Sk if (skill === undefined) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown skill URI: ${params.uri}`); } - return { skill, ...hints }; + return { skill, ...hintsForRequest() }; }); return { skills: byUri }; diff --git a/packages/server/test/ext/skills/installSkills.test.ts b/packages/server/test/ext/skills/installSkills.test.ts index abdd4b9aa3..f3c70cc048 100644 --- a/packages/server/test/ext/skills/installSkills.test.ts +++ b/packages/server/test/ext/skills/installSkills.test.ts @@ -9,13 +9,14 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, - setNegotiatedProtocolVersion + setNegotiatedProtocolVersion, + SUPPORTED_PROTOCOL_VERSIONS } 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 { installSkills, type InstallSkillsOptions, type SkillsCacheHint } from '../../../src/ext/skills'; import { invoke } from '../../../src/server/invoke'; import { Server } from '../../../src/server/server'; @@ -39,15 +40,19 @@ const skill = (name: string): Skill => ({ resources: [{ uri: `skill://${name}/SKILL.md`, digest: digest('a'), size: 64 }] }); -function serverWith(skills: readonly Skill[], options: { pageSize?: number; cacheHint?: { ttlMs?: number } } = {}): Server { +function serverWith(skills: readonly Skill[], options: Omit = {}): 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 }); +async function call( + server: Server, + message: JSONRPCRequest, + classification: MessageClassification = MODERN +): Promise> { + setNegotiatedProtocolVersion(server, classification.revision); + const response = await invoke(server, message, { classification }); const body = (await response.json()) as Record; return body; } @@ -98,19 +103,36 @@ describe('skills/list', () => { 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); }); +}); + +describe.each(['skills/list', 'skills/get'])('%s cache hints on the wire', method => { + const params = method === 'skills/get' ? { uri: 'skill://alpha/SKILL.md' } : {}; + const payload = method === 'skills/get' ? { skill: skill('alpha') } : { skills: [skill('alpha')] }; + + it.each<{ name: string; cacheHint?: SkillsCacheHint; ttlMs: number; cacheScope: string }>([ + { name: 'omitted', ttlMs: 0, cacheScope: 'private' }, + { name: 'empty', cacheHint: {}, ttlMs: 0, cacheScope: 'private' }, + { name: 'TTL only', cacheHint: { ttlMs: 60_000 }, ttlMs: 60_000, cacheScope: 'private' }, + { name: 'scope only', cacheHint: { cacheScope: 'public' }, ttlMs: 0, cacheScope: 'public' }, + { name: 'both', cacheHint: { ttlMs: 60_000, cacheScope: 'public' }, ttlMs: 60_000, cacheScope: 'public' } + ])('fills modern fields when the hint is $name', async ({ cacheHint, ttlMs, cacheScope }) => { + const server = serverWith([skill('alpha')], { cacheHint }); + const result = resultOf(await call(server, request(method, params))); + expect(result).toMatchObject({ ...payload, resultType: 'complete', ttlMs, cacheScope }); + }); - 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.each(SUPPORTED_PROTOCOL_VERSIONS)('omits modern fields on %s even with configured hints', async revision => { + for (const cacheHint of [undefined, { ttlMs: 60_000, cacheScope: 'public' }] as const) { + const server = serverWith([skill('alpha')], { cacheHint }); + const result = resultOf(await call(server, { jsonrpc: '2.0', id: 1, method, params }, { era: 'legacy', revision })); + expect(result).toEqual(payload); + } }); - 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); + it('does not let modern-looking metadata override the negotiated legacy era', async () => { + const server = serverWith([skill('alpha')], { cacheHint: { ttlMs: 60_000, cacheScope: 'public' } }); + const result = resultOf(await call(server, request(method, params), { era: 'legacy', revision: '2025-11-25' })); + expect(result).toEqual(payload); }); }); From 94976dd82f4d0be20b4e148c8e193b06fe113a81 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 03:47:56 +0300 Subject: [PATCH 04/45] feat(core): align Skills extension schemas with the stable spec Loose object schemas throughout, _meta on Skill, resources/directory/read params and result schemas with caching fields, SKILL_LIMITS in place of the schema-level 512-entry cap. Co-authored-by: tobi-oye --- packages/core/src/ext/skills/constants.ts | 43 +++-- packages/core/src/ext/skills/index.ts | 26 +-- packages/core/src/ext/skills/schemas.ts | 106 +++++-------- packages/core/src/ext/skills/types.ts | 34 ++-- packages/core/test/ext/skills/schemas.test.ts | 148 +++++++++--------- 5 files changed, 161 insertions(+), 196 deletions(-) diff --git a/packages/core/src/ext/skills/constants.ts b/packages/core/src/ext/skills/constants.ts index 04fe346b00..dd5ca183a5 100644 --- a/packages/core/src/ext/skills/constants.ts +++ b/packages/core/src/ext/skills/constants.ts @@ -1,43 +1,38 @@ /** * 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. + * Runtime-neutral: this module imports nothing. */ -/** - * The reverse-DNS identifier under which a server advertises the Skills - * extension in `ServerCapabilities.extensions`. - */ +/** The identifier a server advertises in `ServerCapabilities.extensions`. */ export const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; -/** Request method that enumerates the skills a server serves. */ +/** 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. */ +/** Returns one skill's entry by its `SKILL.md` URI, listed or not. */ export const SKILLS_GET_METHOD = 'skills/get'; -/** The canonical URI scheme for skills. Servers MAY serve skills under other schemes. */ +/** Lists the direct children of a directory resource (gated by `directoryRead`). */ +export const RESOURCES_DIRECTORY_READ_METHOD = 'resources/directory/read'; + +/** The conventional URI scheme for skills. Servers MAY use other schemes. */ export const SKILL_URI_SCHEME = 'skill:'; -/** The manifest filename that terminates every `SKILL.md` URI. */ +/** The manifest file that every skill entry's `uri` points at. */ 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; +/** The `mimeType` of a directory resource. */ +export const DIRECTORY_MIME_TYPE = 'inode/directory'; -/** - * 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; +/** Reverse-domain prefix for `_meta` keys this extension defines. */ +export const SKILLS_META_PREFIX = 'io.modelcontextprotocol.skills/'; /** - * The digest format required on every skill resource entry: lowercase - * `sha256:` followed by 64 hex characters. + * Per-skill interoperability limits. Hosts MUST accept skills up to these; + * servers SHOULD NOT publish skills over them. */ -export const SKILL_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; +export const SKILL_LIMITS: Readonly<{ files: number; bytes: number }> = Object.freeze({ files: 512, bytes: 16_777_216 }); + +/** `sha256:` followed by 64 lowercase hexadecimal characters. */ +export const SKILL_DIGEST_PATTERN = /^sha256:[\da-f]{64}$/; diff --git a/packages/core/src/ext/skills/index.ts b/packages/core/src/ext/skills/index.ts index 0bad5a3a5a..af480cd838 100644 --- a/packages/core/src/ext/skills/index.ts +++ b/packages/core/src/ext/skills/index.ts @@ -1,31 +1,32 @@ // @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`. +// Shared surface of the MCP Skills extension (SEP-2640): wire constants, Zod +// schemas, the types inferred from them, and the capability reader. Adapted, +// with attribution, from the Apache-2.0 `@olaservo/ext-skills` prototype +// (modelcontextprotocol/ext-skills#71) and typescript-sdk#2818. export type { CapabilitiesWithExtensions } from './capability'; export { skillsCapabilityOf } from './capability'; export { - MAX_SKILL_RESOURCES, - MAX_SKILL_TOTAL_BYTES, + DIRECTORY_MIME_TYPE, + RESOURCES_DIRECTORY_READ_METHOD, SKILL_DIGEST_PATTERN, + SKILL_LIMITS, SKILL_MANIFEST_FILENAME, SKILL_URI_SCHEME, SKILLS_EXTENSION_ID, SKILLS_GET_METHOD, - SKILLS_LIST_METHOD + SKILLS_LIST_METHOD, + SKILLS_META_PREFIX } from './constants'; export { + DirectoryResourceSchema, GetSkillRequestParamsSchema, GetSkillResultSchema, ListSkillsRequestParamsSchema, ListSkillsResultSchema, + ReadResourceDirectoryRequestParamsSchema, + ReadResourceDirectoryResultSchema, SkillCacheScopeSchema, SkillDigestSchema, SkillFrontmatterSchema, @@ -35,10 +36,13 @@ export { SkillSchema } from './schemas'; export type { + DirectoryResource, GetSkillRequestParams, GetSkillResult, ListSkillsRequestParams, ListSkillsResult, + ReadResourceDirectoryRequestParams, + ReadResourceDirectoryResult, Skill, SkillCacheScope, SkillFrontmatter, diff --git a/packages/core/src/ext/skills/schemas.ts b/packages/core/src/ext/skills/schemas.ts index 6e8a04b37c..9bb877ad49 100644 --- a/packages/core/src/ext/skills/schemas.ts +++ b/packages/core/src/ext/skills/schemas.ts @@ -1,109 +1,81 @@ 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({ +import { BaseRequestParamsSchema, PaginatedRequestParamsSchema, PaginatedResultSchema, ResourceSchema, ResultSchema } from '../../schemas'; +import { SKILL_DIGEST_PATTERN } from './constants'; + +// Every object schema here is loose: none of the stable spec's types declare +// `additionalProperties: false`, so unknown keys (including `_meta`) survive. + +export const SkillDigestSchema = z + .string() + .regex(SKILL_DIGEST_PATTERN, 'digest must be "sha256:" followed by 64 lowercase hexadecimal characters'); + +export const SkillResourceEntrySchema = z.looseObject({ /** Resource URI of the file. */ uri: z.string(), /** SHA-256 digest of the file's raw bytes. */ digest: SkillDigestSchema, - /** The file's length in bytes. */ + /** Length of the file's raw 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')]); +export const SkillResourcesSchema = z.union([z.array(SkillResourceEntrySchema), z.literal('dynamic')]); -/** A single skill served by an MCP server. */ -export const SkillSchema = z.object({ +export const SkillSchema = z.looseObject({ /** Resource URI of the skill's `SKILL.md`. */ uri: z.string(), - /** The manifest's frontmatter, verbatim. */ + /** The `SKILL.md` frontmatter, verbatim. */ frontmatter: SkillFrontmatterSchema, - /** The skill's files, or `"dynamic"`. */ - resources: SkillResourcesSchema + /** Every file of the skill, or `"dynamic"`. */ + resources: SkillResourcesSchema, + _meta: z.record(z.string(), z.unknown()).optional() }); -/** 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. */ +const cacheFields = { ttlMs: z.number().int().nonnegative().optional(), - /** Cache scope for the result. */ cacheScope: SkillCacheScopeSchema.optional() }; -/** Result of `skills/list`. */ -export const ListSkillsResultSchema = PaginatedResultSchema.extend({ +export const ListSkillsResultSchema = z.looseObject({ + ...PaginatedResultSchema.shape, skills: z.array(SkillSchema), - ...skillCacheHintShape + ...cacheFields }); -/** 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({ +export const GetSkillResultSchema = z.looseObject({ + ...ResultSchema.shape, skill: SkillSchema, - ...skillCacheHintShape + ...cacheFields +}); + +export const DirectoryResourceSchema = z.looseObject(ResourceSchema.shape); + +export const ReadResourceDirectoryRequestParamsSchema = PaginatedRequestParamsSchema.extend({ + /** URI of the directory resource to read. */ + uri: z.string() +}); + +export const ReadResourceDirectoryResultSchema = z.looseObject({ + ...PaginatedResultSchema.shape, + resources: z.array(DirectoryResourceSchema), + ...cacheFields }); -/** - * 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`. */ + /** Whether the server serves `resources/directory/read`. */ 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 index 77c2c9953f..bab2c6e1ee 100644 --- a/packages/core/src/ext/skills/types.ts +++ b/packages/core/src/ext/skills/types.ts @@ -1,10 +1,13 @@ import type * as z from 'zod/v4'; import type { + DirectoryResourceSchema, GetSkillRequestParamsSchema, GetSkillResultSchema, ListSkillsRequestParamsSchema, ListSkillsResultSchema, + ReadResourceDirectoryRequestParamsSchema, + ReadResourceDirectoryResultSchema, SkillCacheScopeSchema, SkillFrontmatterSchema, SkillResourceEntrySchema, @@ -13,32 +16,29 @@ import type { SkillSchema } from './schemas'; -/** One file belonging to a skill, with the material a host needs to verify it. */ +/** One file of a skill, with what a host needs to verify it. */ export type SkillResourceEntry = z.infer; - -/** The YAML frontmatter of a skill's `SKILL.md`, converted to JSON verbatim. */ +/** A skill's `SKILL.md` frontmatter as JSON, verbatim. */ export type SkillFrontmatter = z.infer; - -/** A skill's file set: the complete list of entries, or the literal `"dynamic"`. */ +/** A skill's complete file list, or `"dynamic"`. */ export type SkillResources = z.infer; - -/** A single skill served by an MCP server. */ +/** One skill entry, as `skills/list` and `skills/get` carry it. */ export type Skill = z.infer; - -/** Whether a result may be cached by shared caches (`public`) or only by the requesting client (`private`). */ +/** `public` or `private`. */ export type SkillCacheScope = z.infer; - -/** Params for `skills/list`. */ +/** Params of `skills/list`. */ export type ListSkillsRequestParams = z.infer; - /** Result of `skills/list`. */ export type ListSkillsResult = z.infer; - -/** Params for `skills/get`. */ +/** Params of `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"]`. */ +/** A child of a directory resource. */ +export type DirectoryResource = z.infer; +/** Params of `resources/directory/read`. */ +export type ReadResourceDirectoryRequestParams = z.infer; +/** Result of `resources/directory/read`. */ +export type ReadResourceDirectoryResult = z.infer; +/** The value at `capabilities.extensions["io.modelcontextprotocol/skills"]`. */ export type SkillsCapability = z.infer; diff --git a/packages/core/test/ext/skills/schemas.test.ts b/packages/core/test/ext/skills/schemas.test.ts index bfceb9be5c..7a417933a7 100644 --- a/packages/core/test/ext/skills/schemas.test.ts +++ b/packages/core/test/ext/skills/schemas.test.ts @@ -1,112 +1,106 @@ -/** - * 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 { + GetSkillRequestParamsSchema, GetSkillResultSchema, - ListSkillsRequestParamsSchema, ListSkillsResultSchema, - MAX_SKILL_RESOURCES, - SKILLS_EXTENSION_ID, - SkillFrontmatterSchema, - SkillResourceEntrySchema, + ReadResourceDirectoryRequestParamsSchema, + ReadResourceDirectoryResultSchema, + SKILL_LIMITS, SkillSchema, - skillsCapabilityOf -} from '../../../src/ext/skills'; + skillsCapabilityOf, + SKILLS_EXTENSION_ID +} from '../../../src/ext/skills/index'; -const DIGEST = `sha256:${'a'.repeat(64)}`; - -const SKILL = { +const digest = `sha256:${'a'.repeat(64)}`; +const skill = (extra: Record = {}) => ({ 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); - }); + frontmatter: { name: 'git-workflow', description: 'Branching and review conventions.' }, + resources: [{ uri: 'skill://git-workflow/SKILL.md', digest, size: 10 }], + ...extra +}); - 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); +describe('SkillSchema', () => { + it('keeps _meta and unknown keys', () => { + const parsed = SkillSchema.parse(skill({ _meta: { 'io.modelcontextprotocol.skills/x': 1 }, future: true })); + expect(parsed._meta).toEqual({ 'io.modelcontextprotocol.skills/x': 1 }); + expect((parsed as Record)['future']).toBe(true); }); - it('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); + it('keeps frontmatter fields beyond name and description', () => { + const parsed = SkillSchema.parse(skill({ frontmatter: { name: 'git-workflow', description: 'd', license: 'MIT' } })); + expect(parsed.frontmatter).toEqual({ name: 'git-workflow', description: 'd', license: 'MIT' }); }); -}); -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 more files than SKILL_LIMITS (limits are policy, not schema)', () => { + const resources = Array.from({ length: SKILL_LIMITS.files + 1 }, (_, index) => ({ + uri: `skill://git-workflow/f${index}`, + digest, + size: 1 + })); + expect(SkillSchema.safeParse(skill({ resources })).success).toBe(true); }); - it('accepts 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('accepts "dynamic" and rejects any other string', () => { + expect(SkillSchema.safeParse(skill({ resources: 'dynamic' })).success).toBe(true); + expect(SkillSchema.safeParse(skill({ resources: 'static' })).success).toBe(false); }); - it(`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); + it('rejects malformed digests and negative sizes', () => { + expect( + SkillSchema.safeParse(skill({ resources: [{ uri: 'skill://git-workflow/SKILL.md', digest: 'sha256:ABC', size: 1 }] })).success + ).toBe(false); + expect(SkillSchema.safeParse(skill({ resources: [{ uri: 'skill://git-workflow/SKILL.md', digest, size: -1 }] })).success).toBe( + false + ); }); }); -describe('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(); +describe('result schemas', () => { + it('ListSkillsResult keeps paging and caching fields and needs no resultType', () => { + const parsed = ListSkillsResultSchema.parse({ skills: [skill()], nextCursor: 'c', ttlMs: 5, cacheScope: 'public' }); + expect(parsed).toMatchObject({ nextCursor: 'c', ttlMs: 5, cacheScope: 'public' }); }); - it('parses a skills/get body with no resultType', () => { - expect(GetSkillResultSchema.parse({ skill: SKILL }).skill.uri).toBe(SKILL.uri); + it('GetSkillResult carries one entry', () => { + expect(GetSkillResultSchema.parse({ skill: skill(), ttlMs: 0, cacheScope: 'private' }).skill.uri).toBe( + 'skill://git-workflow/SKILL.md' + ); }); - it('rejects an unknown cacheScope rather than passing it through', () => { - expect(ListSkillsResultSchema.safeParse({ skills: [], cacheScope: 'shared' }).success).toBe(false); + it('ReadResourceDirectoryResult keeps unknown keys on children and caching fields', () => { + const parsed = ReadResourceDirectoryResultSchema.parse({ + resources: [{ uri: 'skill://git-workflow/references', name: 'references', mimeType: 'inode/directory', extra: 1 }], + ttlMs: 0, + cacheScope: 'private' + }); + expect((parsed.resources[0] as Record)['extra']).toBe(1); + expect(parsed.ttlMs).toBe(0); }); }); -describe('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('request params', () => { + it('skills/get requires a uri', () => { + expect(GetSkillRequestParamsSchema.safeParse({}).success).toBe(false); + expect(GetSkillRequestParamsSchema.parse({ uri: 'skill://a/SKILL.md' }).uri).toBe('skill://a/SKILL.md'); }); -}); -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('resources/directory/read takes a uri and an optional cursor', () => { + expect(ReadResourceDirectoryRequestParamsSchema.parse({ uri: 'skill://a', cursor: 'x' })).toMatchObject({ + uri: 'skill://a', + cursor: 'x' + }); }); +}); - 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(); +describe('constants and capability', () => { + it('SKILL_LIMITS matches the SEP', () => { + expect(SKILL_LIMITS).toEqual({ files: 512, bytes: 16_777_216 }); }); - it('preserves capability fields added by later revisions', () => { - expect(skillsCapabilityOf({ extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: false, future: 1 } } })).toEqual({ - directoryRead: false, - future: 1 - }); + it('skillsCapabilityOf reads the extension entry', () => { + expect(skillsCapabilityOf({ extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: true } } })).toEqual({ directoryRead: true }); + expect(skillsCapabilityOf({ extensions: {} })).toBeUndefined(); + expect(skillsCapabilityOf(undefined)).toBeUndefined(); }); }); From 5515d03b701276d9ce86d70756615b5bc3d3760e Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 04:08:14 +0300 Subject: [PATCH 05/45] feat(core-internal): skills frontmatter parser Built-in YAML-subset reader for SKILL.md frontmatter (the subset the C# SDK reads, YAML 1.2 core-schema typing, fail-closed on anchors, aliases, tags, duplicate keys and deep nesting), with a differential test against the yaml library (devDependency only). --- packages/core-internal/package.json | 3 +- .../src/ext/skills/frontmatter.ts | 522 ++++++++++++++++++ .../core-internal/src/ext/skills/index.ts | 4 + packages/core-internal/src/index.ts | 1 + .../skills/frontmatter.differential.test.ts | 76 +++ .../test/ext/skills/frontmatter.test.ts | 144 +++++ pnpm-lock.yaml | 3 + 7 files changed, 752 insertions(+), 1 deletion(-) create mode 100644 packages/core-internal/src/ext/skills/frontmatter.ts create mode 100644 packages/core-internal/src/ext/skills/index.ts create mode 100644 packages/core-internal/test/ext/skills/frontmatter.differential.test.ts create mode 100644 packages/core-internal/test/ext/skills/frontmatter.test.ts diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 86b79e20b9..78abd04715 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -97,6 +97,7 @@ "prettier": "catalog:devTools", "typescript": "catalog:devTools", "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "vitest": "catalog:devTools", + "yaml": "^2.8.3" } } diff --git a/packages/core-internal/src/ext/skills/frontmatter.ts b/packages/core-internal/src/ext/skills/frontmatter.ts new file mode 100644 index 0000000000..ac04ebcad8 --- /dev/null +++ b/packages/core-internal/src/ext/skills/frontmatter.ts @@ -0,0 +1,522 @@ +/** + * Frontmatter reader for `SKILL.md` (SEP-2640). + * + * Reads the YAML subset real skills use — block mappings and sequences; + * plain, quoted and block scalars; one-line flow collections; comments — with + * YAML 1.2 core-schema typing. Anything outside the subset throws a + * {@linkcode FrontmatterError} instead of being read differently from a full + * YAML parser. Server catalogs and client verification both call it; it is + * kept behind this module so it can be swapped for a YAML library. + */ + +export class FrontmatterError extends Error { + readonly line: number | undefined; + + constructor(message: string, line?: number) { + super(line === undefined ? message : `line ${line}: ${message}`); + this.name = 'FrontmatterError'; + this.line = line; + } +} + +export interface SplitSkillMarkdown { + /** The YAML between the opening and closing `---` lines, with LF line endings. */ + yaml: string; + /** The markdown after the closing `---` line. */ + body: string; +} + +export interface ParsedSkillMarkdown { + frontmatter: Record; + body: string; +} + +const MAX_DEPTH = 32; + +/** Splits `SKILL.md` into its frontmatter YAML and markdown body. */ +export function splitFrontmatter(markdown: string): SplitSkillMarkdown { + let text = markdown.replaceAll('\r\n', '\n'); + if (text.codePointAt(0) === 0xfe_ff) text = text.slice(1); + if (!text.startsWith('---\n')) throw new FrontmatterError('SKILL.md must begin with a "---" line', 1); + const rest = text.slice(4); + const close = /^---[\t ]*$/m.exec(rest); + if (close === null) throw new FrontmatterError('frontmatter has no closing "---" line'); + let body = rest.slice(close.index + close[0].length); + if (body.startsWith('\n')) body = body.slice(1); + return { yaml: rest.slice(0, close.index), body }; +} + +/** Splits and parses `SKILL.md`. Line numbers in errors count from the file's first line. */ +export function parseSkillMarkdown(markdown: string): ParsedSkillMarkdown { + const { yaml, body } = splitFrontmatter(markdown); + return { frontmatter: parseFrontmatterYaml(yaml, 2), body }; +} + +/** Parses frontmatter YAML into a JSON object. `firstLine` numbers the first YAML line in errors. */ +export function parseFrontmatterYaml(yaml: string, firstLine = 1): Record { + return new Parser(toLines(yaml, firstLine)).parseDocument(); +} + +interface Line { + readonly no: number; + /** Number of leading spaces. */ + readonly indent: number; + /** The line after its indentation. */ + readonly text: string; + /** The whole line. */ + readonly raw: string; +} + +function toLines(yaml: string, firstLine: number): Line[] { + const raws = yaml.split('\n'); + if (raws.at(-1) === '') raws.pop(); + return raws.map((raw, index) => { + const leading = /^[\t ]*/.exec(raw)?.[0] ?? ''; + const text = raw.slice(leading.length); + const no = firstLine + index; + if (leading.includes('\t') && text !== '' && !text.startsWith('#')) { + throw new FrontmatterError('tabs cannot be used for indentation', no); + } + return { no, indent: leading.length, text, raw }; + }); +} + +const isSkippable = (line: Line): boolean => line.text === '' || line.text.startsWith('#'); +const isSequenceItem = (text: string): boolean => /^-(?:[\t ]|$)/.test(text); + +class Parser { + private index = 0; + + constructor(private readonly lines: Line[]) {} + + parseDocument(): Record { + const first = this.peek(); + if (first === undefined) return {}; + if (isSequenceItem(first.text) || matchKey(first.text, first.no) === undefined) { + throw new FrontmatterError('frontmatter must be a mapping of keys to values', first.no); + } + const map = this.parseMapping(first.indent, 0); + const extra = this.peek(); + if (extra !== undefined) throw new FrontmatterError('unexpected content', extra.no); + return map; + } + + private peek(): Line | undefined { + while (this.index < this.lines.length && isSkippable(this.lines[this.index]!)) this.index++; + return this.lines[this.index]; + } + + private parseMapping(indent: number, depth: number): Record { + assertDepth(depth, this.lines[this.index]?.no); + const map: Record = {}; + for (let line = this.peek(); line !== undefined && line.indent >= indent; line = this.peek()) { + if (line.indent > indent) throw new FrontmatterError('unexpected indentation', line.no); + if (isSequenceItem(line.text)) break; + const match = matchKey(line.text, line.no); + if (match === undefined) throw new FrontmatterError('expected "key: value"', line.no); + if (Object.hasOwn(map, match.key)) throw new FrontmatterError(`duplicate key "${match.key}"`, line.no); + this.index++; + map[match.key] = this.parseValue(match.rest, line, indent, depth + 1, true); + } + return map; + } + + private parseSequence(indent: number, depth: number): unknown[] { + assertDepth(depth, this.lines[this.index]?.no); + const items: unknown[] = []; + for (let line = this.peek(); line !== undefined && line.indent >= indent; line = this.peek()) { + if (line.indent > indent) throw new FrontmatterError('unexpected indentation', line.no); + if (!isSequenceItem(line.text)) break; + const after = line.text.slice(1); + const rest = after.trimStart(); + const column = line.indent + 1 + (after.length - rest.length); + if (rest !== '' && !rest.startsWith('#') && (isSequenceItem(rest) || matchKey(rest, line.no) !== undefined)) { + // `- key: value` or `- - item`: the item is a block node that starts at `column`. + if (/^ *\t/.test(after)) throw new FrontmatterError('tabs cannot be used for indentation', line.no); + this.lines[this.index] = { ...line, indent: column, text: rest }; + items.push(isSequenceItem(rest) ? this.parseSequence(column, depth + 1) : this.parseMapping(column, depth + 1)); + continue; + } + this.index++; + items.push(this.parseValue(rest, line, indent, depth + 1, false)); + } + return items; + } + + private parseValue(rest: string, line: Line, parentIndent: number, depth: number, allowCompactSequence: boolean): unknown { + if (rest === '' || rest.startsWith('#')) { + const next = this.peek(); + if (next !== undefined && next.indent > parentIndent) return this.parseIndented(parentIndent, depth); + if (allowCompactSequence && next !== undefined && next.indent === parentIndent && isSequenceItem(next.text)) { + return this.parseSequence(parentIndent, depth); + } + return null; + } + const first = rest[0]!; + if (first === '|' || first === '>') return this.parseBlockScalar(rest, line, parentIndent); + if (first === '"' || first === "'") { + const { value, end } = readQuoted(rest, 0, line.no); + expectOnlyComment(rest.slice(end), line.no); + return value; + } + if (first === '[' || first === '{') { + const { value, end } = readFlow(rest, 0, line.no, depth); + expectOnlyComment(rest.slice(end), line.no); + return value; + } + if ('&*!'.includes(first)) throw new FrontmatterError('anchors, aliases and tags are not supported', line.no); + if ('%@`,]}'.includes(first)) throw new FrontmatterError(`"${first}" cannot start a plain value; quote it`, line.no); + if (isSequenceItem(rest) || /^\?(?:[\t ]|$)/.test(rest)) { + throw new FrontmatterError('a nested collection must start on its own line', line.no); + } + return this.parsePlain(rest, line, parentIndent); + } + + private parseIndented(parentIndent: number, depth: number): unknown { + const line = this.peek()!; + if (isSequenceItem(line.text)) return this.parseSequence(line.indent, depth); + if (matchKey(line.text, line.no) !== undefined) return this.parseMapping(line.indent, depth); + this.index++; + return this.parseValue(line.text, line, parentIndent, depth, false); + } + + private parsePlain(first: string, line: Line, parentIndent: number): unknown { + let value = stripComment(first); + assertPlain(value, line.no); + let commented = COMMENT.test(first); + let blanks = 0; + while (this.index < this.lines.length) { + const next = this.lines[this.index]!; + if (next.text === '') { + blanks++; + this.index++; + continue; + } + if (next.indent <= parentIndent || next.text.startsWith('#')) break; + if (commented) throw new FrontmatterError('a comment ends a plain value; quote a value that continues past it', next.no); + const part = stripComment(next.text); + assertPlain(part, next.no); + commented = COMMENT.test(next.text); + value += blanks > 0 ? '\n'.repeat(blanks) : ' '; + value += part; + blanks = 0; + this.index++; + } + return resolvePlain(value, line.no); + } + + private parseBlockScalar(header: string, line: Line, parentIndent: number): string { + const match = /^([>|])([+-]?)(?:[\t ]+#.*)?[\t ]*$/.exec(header); + if (match === null) { + const indicator = /^[>|][+-]?\d|^[>|]\d/.test(header); + throw new FrontmatterError( + indicator ? 'block scalar indentation indicators are not supported' : 'invalid block scalar header', + line.no + ); + } + const contents: string[] = []; + let contentIndent = -1; + let leadingWidth = 0; + while (this.index < this.lines.length) { + const next = this.lines[this.index]!; + if (next.text === '') { + if (contentIndent === -1) leadingWidth = Math.max(leadingWidth, next.raw.length); + contents.push(contentIndent === -1 ? '' : next.raw.slice(contentIndent)); + this.index++; + continue; + } + if (contentIndent === -1) { + if (next.indent <= parentIndent) break; + contentIndent = next.indent; + if (leadingWidth > contentIndent) { + throw new FrontmatterError('a leading blank line is indented more than the block scalar content', next.no); + } + } + if (next.indent < contentIndent) break; + contents.push(next.raw.slice(contentIndent)); + this.index++; + } + return foldBlock(contents, match[1] === '>', match[2] ?? ''); + } +} + +function assertDepth(depth: number, line: number | undefined): void { + if (depth > MAX_DEPTH) throw new FrontmatterError(`nesting deeper than ${MAX_DEPTH} levels is not supported`, line); +} + +interface KeyMatch { + key: string; + rest: string; +} + +function matchKey(text: string, no: number): KeyMatch | undefined { + const first = text[0]; + if (first === '"' || first === "'") { + const { value, end } = readQuoted(text, 0, no); + const after = text.slice(end); + if (after === ':' || /^:[\t ]/.test(after)) return { key: checkKey(value, no), rest: after.slice(1).trimStart() }; + return undefined; + } + if (first === '?') throw new FrontmatterError('complex keys ("?") are not supported', no); + if (first === undefined || '[]{}&*!|>%@`#,'.includes(first)) return undefined; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + const previous = text[index - 1]; + if (char === '#' && (previous === ' ' || previous === '\t')) return undefined; + if (char === ':') { + const next = text[index + 1]; + if (next === undefined || next === ' ' || next === '\t') { + const key = text.slice(0, index).trimEnd(); + if (key === '') return undefined; + return { key: resolveKey(key, no), rest: text.slice(index + 1).trimStart() }; + } + } + } + return undefined; +} + +function checkKey(key: string, no: number): string { + if (key === '__proto__') throw new FrontmatterError('the key "__proto__" is not supported', no); + return key; +} + +function resolveKey(raw: string, no: number): string { + if (typeof resolvePlain(raw, no) !== 'string') throw new FrontmatterError(`key "${raw}" must be a string; quote it`, no); + return checkKey(raw, no); +} + +function assertPlain(text: string, no: number): void { + if (/:(?:[\t ]|$)/.test(text)) throw new FrontmatterError('a plain value cannot contain ": "; quote the value', no); +} + +const COMMENT = /[\t ]#/; + +function stripComment(text: string): string { + const match = COMMENT.exec(text); + return (match === null ? text : text.slice(0, match.index)).trimEnd(); +} + +function expectOnlyComment(after: string, no: number): void { + if (after.trim() === '' || /^[\t ]+#/.test(after)) return; + throw new FrontmatterError('unexpected text after the value', no); +} + +const NULL = /^(?:~|null|Null|NULL)$/; +const TRUE = /^(?:true|True|TRUE)$/; +const FALSE = /^(?:false|False|FALSE)$/; +const INTEGER = /^[+-]?\d+$/; +const OCTAL = /^0o[0-7]+$/; +const HEXADECIMAL = /^0x[\dA-Fa-f]+$/; +const FLOAT = /^[+-]?(?:\.\d+|\d+(?:\.\d*)?)(?:[Ee][+-]?\d+)?$/; +const SPECIAL_FLOAT = /^(?:[+-]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/; + +function resolvePlain(text: string, no: number): unknown { + if (text === '' || NULL.test(text)) return null; + if (TRUE.test(text)) return true; + if (FALSE.test(text)) return false; + let number: number | undefined; + if (INTEGER.test(text) || FLOAT.test(text)) number = Number(text); + else if (OCTAL.test(text)) number = Number.parseInt(text.slice(2), 8); + else if (HEXADECIMAL.test(text)) number = Number.parseInt(text.slice(2), 16); + else if (SPECIAL_FLOAT.test(text)) number = Number.NaN; + if (number === undefined) return text; + if (!Number.isFinite(number)) throw new FrontmatterError(`"${text}" cannot be represented in JSON; quote it`, no); + return number; +} + +const SIMPLE_ESCAPES: Readonly> = { + '0': '\0', + a: '\u0007', + b: '\b', + t: '\t', + '\t': '\t', + n: '\n', + v: '\v', + f: '\f', + r: '\r', + e: '\u001B', + ' ': ' ', + '"': '"', + '/': '/', + '\\': '\\', + N: '\u0085', + _: '\u00A0', + L: '\u2028', + P: '\u2029' +}; + +const HEX_ESCAPE_WIDTH: Readonly> = { x: 2, u: 4, U: 8 }; + +const UNCLOSED_QUOTE = 'a quoted value must close on the same line (multi-line quoted values are not supported)'; + +function readQuoted(text: string, start: number, no: number): { value: string; end: number } { + const quote = text[start]; + let value = ''; + let index = start + 1; + while (index < text.length) { + const char = text[index]!; + if (quote === "'") { + if (char === "'") { + if (text[index + 1] === "'") { + value += "'"; + index += 2; + continue; + } + return { value, end: index + 1 }; + } + value += char; + index++; + continue; + } + if (char === '"') return { value, end: index + 1 }; + if (char === '\\') { + const escape = decodeEscape(text, index, no); + value += escape.decoded; + index += escape.length; + continue; + } + value += char; + index++; + } + throw new FrontmatterError(UNCLOSED_QUOTE, no); +} + +function decodeEscape(text: string, index: number, no: number): { decoded: string; length: number } { + const code = text[index + 1]; + if (code === undefined) throw new FrontmatterError(UNCLOSED_QUOTE, no); + const simple = SIMPLE_ESCAPES[code]; + if (simple !== undefined) return { decoded: simple, length: 2 }; + const width = HEX_ESCAPE_WIDTH[code]; + if (width === undefined) throw new FrontmatterError(`unknown escape "\\${code}"`, no); + const hex = text.slice(index + 2, index + 2 + width); + if (hex.length !== width || !/^[\dA-Fa-f]+$/.test(hex) || Number.parseInt(hex, 16) > 0x10_ff_ff) { + throw new FrontmatterError(`invalid escape "\\${code}${hex}"`, no); + } + return { decoded: String.fromCodePoint(Number.parseInt(hex, 16)), length: 2 + width }; +} + +function skipSpaces(text: string, index: number): number { + let position = index; + while (text[position] === ' ' || text[position] === '\t') position++; + return position; +} + +const UNCLOSED_FLOW = 'a flow collection must close on the same line'; + +function readFlow(text: string, start: number, no: number, depth: number): { value: unknown; end: number } { + assertDepth(depth, no); + const open = text[start]; + const close = open === '[' ? ']' : '}'; + const list: unknown[] = []; + const map: Record = {}; + let index = skipSpaces(text, start + 1); + if (text[index] === close) return { value: open === '[' ? list : map, end: index + 1 }; + for (;;) { + if (open === '[') { + const item = readFlowValue(text, index, no, depth, ',]'); + list.push(item.value); + index = skipSpaces(text, item.end); + } else { + const key = readFlowKey(text, index, no); + const item = readFlowValue(text, key.end + 1, no, depth, ',}'); + if (Object.hasOwn(map, key.key)) throw new FrontmatterError(`duplicate key "${key.key}"`, no); + map[key.key] = item.value; + index = skipSpaces(text, item.end); + } + const char = text[index]; + if (char === ',') { + index = skipSpaces(text, index + 1); + if (text[index] === close) return { value: open === '[' ? list : map, end: index + 1 }; + continue; + } + if (char === close) return { value: open === '[' ? list : map, end: index + 1 }; + if (char === undefined) throw new FrontmatterError(UNCLOSED_FLOW, no); + throw new FrontmatterError(`unexpected "${char}" in a flow collection`, no); + } +} + +function readFlowKey(text: string, start: number, no: number): { key: string; end: number } { + const index = skipSpaces(text, start); + const char = text[index]; + if (char === '"' || char === "'") { + const quoted = readQuoted(text, index, no); + const colon = skipSpaces(text, quoted.end); + if (text[colon] !== ':') throw new FrontmatterError('expected ": " after a key in a flow mapping', no); + return { key: checkKey(quoted.value, no), end: colon }; + } + let end = index; + while (end < text.length && !':,}'.includes(text[end]!)) end++; + const raw = text.slice(index, end).trim(); + const after = text[end + 1]; + if (raw === '' || text[end] !== ':' || (after !== ' ' && after !== '\t')) { + throw new FrontmatterError('expected "key: value" in a flow mapping', no); + } + assertFlowPlainStart(text, index, no); + return { key: resolveKey(raw, no), end }; +} + +/** Rejects a plain flow key or value whose first character YAML reads as an indicator. */ +function assertFlowPlainStart(text: string, index: number, no: number): void { + const char = text[index]!; + if ('&*!'.includes(char)) throw new FrontmatterError('anchors, aliases and tags are not supported', no); + if ('|>%@`#'.includes(char) || ('-?:'.includes(char) && ' \t,[]{}'.includes(text[index + 1] ?? ' '))) { + throw new FrontmatterError(`"${char}" cannot start a plain value in a flow collection; quote it`, no); + } +} + +function readFlowValue(text: string, start: number, no: number, depth: number, terminators: string): { value: unknown; end: number } { + const index = skipSpaces(text, start); + const char = text[index]; + if (char === undefined) throw new FrontmatterError(UNCLOSED_FLOW, no); + if (char === '[' || char === '{') return readFlow(text, index, no, depth + 1); + if (char === '"' || char === "'") return readQuoted(text, index, no); + assertFlowPlainStart(text, index, no); + let end = index; + while (end < text.length && !terminators.includes(text[end]!)) { + const current = text[end]; + const previous = text[end - 1]; + if (current === '#' && (previous === ' ' || previous === '\t')) break; + if (current === ':' && (text[end + 1] === undefined || text[end + 1] === ' ' || text[end + 1] === '\t')) { + throw new FrontmatterError('mappings inside flow sequences are not supported', no); + } + end++; + } + const raw = text.slice(index, end).trim(); + if (raw === '') throw new FrontmatterError('empty value in a flow collection', no); + return { value: resolvePlain(raw, no), end }; +} + +/** Joins block scalar lines the way YAML does (algorithm as in js-yaml's readBlockScalar). */ +function foldBlock(contents: readonly string[], folded: boolean, chomping: string): string { + let result = ''; + let emptyLines = 0; + let didReadContent = false; + let atMoreIndented = false; + for (const content of contents) { + if (content === '') { + emptyLines++; + continue; + } + if (folded) { + if (content.startsWith(' ') || content.startsWith('\t')) { + atMoreIndented = true; + result += '\n'.repeat(didReadContent ? 1 + emptyLines : emptyLines); + } else if (atMoreIndented) { + atMoreIndented = false; + result += '\n'.repeat(emptyLines + 1); + } else if (emptyLines === 0) { + if (didReadContent) result += ' '; + } else { + result += '\n'.repeat(emptyLines); + } + } else { + result += '\n'.repeat(didReadContent ? 1 + emptyLines : emptyLines); + } + result += content; + didReadContent = true; + emptyLines = 0; + } + if (chomping === '+') result += '\n'.repeat(didReadContent ? 1 + emptyLines : emptyLines); + else if (chomping === '' && didReadContent) result += '\n'; + return result; +} diff --git a/packages/core-internal/src/ext/skills/index.ts b/packages/core-internal/src/ext/skills/index.ts new file mode 100644 index 0000000000..5bb9a19d4f --- /dev/null +++ b/packages/core-internal/src/ext/skills/index.ts @@ -0,0 +1,4 @@ +// Internal helpers for the MCP Skills extension (SEP-2640), shared by the +// client and server packages. Not part of any public entry point. +export type { ParsedSkillMarkdown, SplitSkillMarkdown } from './frontmatter'; +export { FrontmatterError, parseFrontmatterYaml, parseSkillMarkdown, splitFrontmatter } from './frontmatter'; diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 0aa5aae21a..3c3514737f 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -1,6 +1,7 @@ export * from './auth/errors'; export * from './errors/crossBundleBrand'; export * from './errors/sdkErrors'; +export * from './ext/skills/index'; export * from './shared/auth'; export * from './shared/authUtils'; export * from './shared/clientCapabilityRequirements'; diff --git a/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts new file mode 100644 index 0000000000..eb52699cd9 --- /dev/null +++ b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import YAML from 'yaml'; + +import { FrontmatterError, parseFrontmatterYaml } from '../../../src/ext/skills/frontmatter'; + +/** Real-world-shaped frontmatter the parser must read exactly as a YAML 1.2 library does. */ +const ACCEPTED: readonly string[] = [ + 'name: pdf-processing\ndescription: Extract PDF text, fill forms, merge files. Use when handling PDFs.\n', + 'name: pdf-processing\ndescription: d\nlicense: Apache-2.0\nmetadata:\n author: example-org\n version: "1.0"\n', + 'name: x\ndescription: d\nallowed-tools: Bash(git:*) Bash(jq:*) Read\ndisable-model-invocation: true\nargument-hint: "[file]"\n', + 'name: x\ndescription: >-\n Extracts text and tables from PDF files,\n fills PDF forms.\n\n Use when working with PDFs.\n', + 'name: x\ndescription: |\n Line one\n Line two\n\n Line four\n', + 'name: x\ndescription: >\n folded\n more indented\n back\n', + 'name: x\ndescription: |+\n keep\n\n\nnext: 1\n', + 'name: x\ndescription: first line\n continued\n', + 'name: x\ndescription: d\ntags:\n- a\n- b\nitems:\n - name: one\n weight: 1.5\n - name: two\n', + 'name: x\ndescription: d\nflow: [a, "b c", 1, true, null, {k: v}]\nmap: {a: 1, b: [x, y]}\n', + "name: x\ndescription: 'it''s quoted'\nescaped: \"tab\\tnewline\\nunicode \\u00e9 \\x41\"\n", + 'name: x # trailing comment\n# full-line comment\ndescription: d\nlang: c#\nurl: https://example.test/a#b\n', + 'name: x\ndescription: d\nints: [0, -1, +2, 0x1f, 0o17]\nfloats: [1.0, .5, 1e3, -2.5E-2]\nnulls: [~, null, Null]\nbools: [true, False, TRUE]\nstrings: [yes, no, on, off, 1.2.3]\n', + 'name: x\ndescription: d\nempty:\nquoted-empty: ""\nunicode: "日本語 ✓"\n', + 'name: x\ndescription: d\nnested:\n level1:\n level2:\n - deep\n', + 'name: x\ndescription: d\ntags:\n-\ta\n-\tb\n', + 'name: x\ndescription: d\nflow: [-a, ?b, :c]\nmap: {-k: v, ?k: w}\n' +]; + +/** Valid YAML the parser deliberately rejects (it must fail closed, never misread). */ +const REJECTED: readonly string[] = [ + 'base: &b 1\nref: *b\n', + 'value: !!str 1\n', + 'list: [1,\n 2]\n', + 'quoted: "line\n continued"\n', + 'block: |2\n indented\n', + 'flow: [? x]\n', + 'flow: [:]\n', + 'map: {? k: v}\n', + 'map: {!t k: v}\n', + 'map: {&a k: v}\n' +]; + +/** Invalid YAML: the yaml library rejects it, so the parser must too. */ +const INVALID: readonly string[] = [ + 'a: ,x\n', + 'a: ]x\n', + 'a: ?\tx\n', + ']k: v\n', + 'a: -\tb\n', + 'k:\n-\tk: v\n', + 'a: [- x]\n', + 'a: [-]\n', + 'a: [|x]\n', + 'a: [#x]\n', + 'a: {- k: v}\n', + 'a: {|k: v}\n', + 'a: x #c\n y\n', + 'a: |\n \n x\n', + 'a: "\\U00110000"\n' +]; + +const OPTIONS = { version: '1.2', schema: 'core', uniqueKeys: true, merge: false } as const; + +describe('frontmatter parser agrees with the yaml library (YAML 1.2 core schema)', () => { + it.each(ACCEPTED)('reads %j exactly like yaml', source => { + const expected = YAML.parse(source, OPTIONS) as unknown; + expect(parseFrontmatterYaml(source)).toEqual(expected); + }); + + it.each(REJECTED)('fails closed on %j', source => { + expect(() => parseFrontmatterYaml(source)).toThrow(FrontmatterError); + }); + + it.each(INVALID)('rejects %j like yaml', source => { + expect(() => YAML.parse(source, OPTIONS)).toThrow(); + expect(() => parseFrontmatterYaml(source)).toThrow(FrontmatterError); + }); +}); diff --git a/packages/core-internal/test/ext/skills/frontmatter.test.ts b/packages/core-internal/test/ext/skills/frontmatter.test.ts new file mode 100644 index 0000000000..73d5e7563a --- /dev/null +++ b/packages/core-internal/test/ext/skills/frontmatter.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import { FrontmatterError, parseFrontmatterYaml, parseSkillMarkdown, splitFrontmatter } from '../../../src/ext/skills/frontmatter'; + +describe('splitFrontmatter', () => { + it('splits frontmatter and body', () => { + expect(splitFrontmatter('---\nname: a\n---\n# Body\n')).toEqual({ yaml: 'name: a\n', body: '# Body\n' }); + }); + + it('accepts CRLF line endings and a leading UTF-8 BOM', () => { + expect(splitFrontmatter('\uFEFF---\r\nname: a\r\n---\r\nBody\r\n')).toEqual({ yaml: 'name: a\n', body: 'Body\n' }); + }); + + it('rejects a file without frontmatter or without a closing line', () => { + expect(() => splitFrontmatter('# no frontmatter')).toThrow(FrontmatterError); + expect(() => splitFrontmatter('---\nname: a\n')).toThrow(/closing/); + }); +}); + +describe('parseFrontmatterYaml: scalars', () => { + it('types unquoted values with the YAML 1.2 core schema', () => { + expect( + parseFrontmatterYaml( + ['a: true', 'b: False', 'c: ~', 'd:', 'e: 42', 'f: -1.5', 'g: 1e3', 'h: 0x1F', 'i: 0o17', 'j: .5', 'k: yes', 'l: 1.0'].join( + '\n' + ) + ) + ).toEqual({ a: true, b: false, c: null, d: null, e: 42, f: -1.5, g: 1000, h: 31, i: 15, j: 0.5, k: 'yes', l: 1 }); + }); + + it('keeps quoted values as strings and decodes escapes', () => { + expect( + parseFrontmatterYaml(String.raw`a: "1.0"` + '\n' + String.raw`b: 'it''s'` + '\n' + String.raw`c: "tab\there \u00e9"`) + ).toEqual({ + a: '1.0', + b: "it's", + c: 'tab\there é' + }); + }); + + it('strips comments only when preceded by whitespace', () => { + expect(parseFrontmatterYaml('lang: c#\nnote: hello # a comment\nurl: http://x.test/a#b')).toEqual({ + lang: 'c#', + note: 'hello', + url: 'http://x.test/a#b' + }); + }); + + it('folds multi-line plain values', () => { + expect(parseFrontmatterYaml('description: first line\n second line\n\n after blank')).toEqual({ + description: 'first line second line\nafter blank' + }); + }); +}); + +describe('parseFrontmatterYaml: block scalars', () => { + it('reads literal and folded scalars with chomping', () => { + const yaml = [ + 'lit: |', + ' a', + ' b', + '', + 'strip: |-', + ' x', + 'keep: |+', + ' y', + '', + 'fold: >', + ' one', + ' two', + '', + ' three', + 'next: 1' + ].join('\n'); + expect(parseFrontmatterYaml(yaml)).toEqual({ lit: 'a\nb\n', strip: 'x', keep: 'y\n\n', fold: 'one two\nthree\n', next: 1 }); + }); +}); + +describe('parseFrontmatterYaml: collections', () => { + it('reads nested mappings, sequences, compact sequences and flow collections', () => { + const yaml = [ + 'metadata:', + ' author: example-org', + ' version: "1.0"', + 'tags:', + '- a', + '- b', + 'items:', + ' - name: x', + ' value: 1', + ' - - nested', + 'flow: [a, "b", {k: v}, ]', + 'map: {x: 1, y: [2, 3]}' + ].join('\n'); + expect(parseFrontmatterYaml(yaml)).toEqual({ + metadata: { author: 'example-org', version: '1.0' }, + tags: ['a', 'b'], + items: [{ name: 'x', value: 1 }, ['nested']], + flow: ['a', 'b', { k: 'v' }], + map: { x: 1, y: [2, 3] } + }); + }); +}); + +describe('parseFrontmatterYaml: fails closed', () => { + it.each([ + ['an anchor', 'a: &x 1'], + ['an alias', 'a: *x'], + ['a tag', 'a: !!str 1'], + ['a complex key', '? a\n: b'], + ['a multi-line flow collection', 'a: [1,\n 2]'], + ['a multi-line quoted value', 'a: "x\n y"'], + ['a duplicate key', 'a: 1\na: 2'], + ['tab indentation', 'a:\n\tb: 1'], + ['": " inside a plain value', 'a: b: c'], + ['a non-finite number', 'a: .inf'], + ['an indentation indicator', 'a: |2\n x'], + ['a top-level sequence', '- a'], + ['a non-string key', '1: a'], + ['the __proto__ key', '__proto__: x'], + ['an unknown escape', String.raw`a: "\q"`], + ['a directive', '%YAML 1.2\na: 1'], + ['an unclosed quote', 'a: "x'] + ])('rejects %s', (_label, yaml) => { + expect(() => parseFrontmatterYaml(yaml)).toThrow(FrontmatterError); + }); + + it('reports the 1-based line number within SKILL.md', () => { + expect(() => parseSkillMarkdown('---\nname: a\nbad: &x 1\n---\n')).toThrow(/line 3/); + }); + + it('rejects nesting deeper than 32 levels', () => { + expect(() => parseFrontmatterYaml(`a: ${'['.repeat(40)}${']'.repeat(40)}`)).toThrow(/deeper than 32/); + }); +}); + +describe('parseSkillMarkdown', () => { + it('returns frontmatter and body', () => { + expect(parseSkillMarkdown('---\nname: git-workflow\ndescription: d\n---\n# Git\n')).toEqual({ + frontmatter: { name: 'git-workflow', description: 'd' }, + body: '# Git\n' + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3cf72692..a8702c141e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1558,6 +1558,9 @@ importers: vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + yaml: + specifier: ^2.8.3 + version: 2.8.3 packages/middleware/express: dependencies: From c77490ce3b3f87a4d09b685eb1d536800816ee54 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 04:21:39 +0300 Subject: [PATCH 06/45] fix(core-internal): fail closed on flow indicators and complex flow keys A ':' before whitespace, a flow indicator or the end of the line is an indicator in flow collections, and a flow key that starts with '[' or '{' is a complex key; both are now rejected instead of read as plain text. Plain flow scalars stop at ',[]{}' and ' #'. --- .../src/ext/skills/frontmatter.ts | 43 +++++++++++-------- .../skills/frontmatter.differential.test.ts | 26 +++++++++-- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/core-internal/src/ext/skills/frontmatter.ts b/packages/core-internal/src/ext/skills/frontmatter.ts index ac04ebcad8..7fb1238f78 100644 --- a/packages/core-internal/src/ext/skills/frontmatter.ts +++ b/packages/core-internal/src/ext/skills/frontmatter.ts @@ -413,12 +413,12 @@ function readFlow(text: string, start: number, no: number, depth: number): { val if (text[index] === close) return { value: open === '[' ? list : map, end: index + 1 }; for (;;) { if (open === '[') { - const item = readFlowValue(text, index, no, depth, ',]'); + const item = readFlowValue(text, index, no, depth); list.push(item.value); index = skipSpaces(text, item.end); } else { const key = readFlowKey(text, index, no); - const item = readFlowValue(text, key.end + 1, no, depth, ',}'); + const item = readFlowValue(text, key.end + 1, no, depth); if (Object.hasOwn(map, key.key)) throw new FrontmatterError(`duplicate key "${key.key}"`, no); map[key.key] = item.value; index = skipSpaces(text, item.end); @@ -438,49 +438,58 @@ function readFlow(text: string, start: number, no: number, depth: number): { val function readFlowKey(text: string, start: number, no: number): { key: string; end: number } { const index = skipSpaces(text, start); const char = text[index]; + if (char === undefined) throw new FrontmatterError(UNCLOSED_FLOW, no); if (char === '"' || char === "'") { const quoted = readQuoted(text, index, no); const colon = skipSpaces(text, quoted.end); if (text[colon] !== ':') throw new FrontmatterError('expected ": " after a key in a flow mapping', no); return { key: checkKey(quoted.value, no), end: colon }; } - let end = index; - while (end < text.length && !':,}'.includes(text[end]!)) end++; + if (char === '[' || char === '{') throw new FrontmatterError('complex keys (flow collections as keys) are not supported', no); + assertFlowPlainStart(text, index, no); + const end = scanFlowPlain(text, index); const raw = text.slice(index, end).trim(); const after = text[end + 1]; if (raw === '' || text[end] !== ':' || (after !== ' ' && after !== '\t')) { throw new FrontmatterError('expected "key: value" in a flow mapping', no); } - assertFlowPlainStart(text, index, no); return { key: resolveKey(raw, no), end }; } +/** Whitespace and the flow indicators: after `-`, `?` or `:`, any of these makes it an indicator in a flow collection. */ +const FLOW_SEPARATORS = ' \t,[]{}'; + /** Rejects a plain flow key or value whose first character YAML reads as an indicator. */ function assertFlowPlainStart(text: string, index: number, no: number): void { const char = text[index]!; if ('&*!'.includes(char)) throw new FrontmatterError('anchors, aliases and tags are not supported', no); - if ('|>%@`#'.includes(char) || ('-?:'.includes(char) && ' \t,[]{}'.includes(text[index + 1] ?? ' '))) { + if ('|>%@`#'.includes(char) || ('-?:'.includes(char) && FLOW_SEPARATORS.includes(text[index + 1] ?? ' '))) { throw new FrontmatterError(`"${char}" cannot start a plain value in a flow collection; quote it`, no); } } -function readFlowValue(text: string, start: number, no: number, depth: number, terminators: string): { value: unknown; end: number } { +/** Returns where a plain flow scalar starting at `index` ends: at a flow indicator, a comment, or a `:` indicator. */ +function scanFlowPlain(text: string, index: number): number { + let end = index; + while (end < text.length) { + const char = text[end]!; + if (',[]{}'.includes(char)) break; + if (char === '#' && (text[end - 1] === ' ' || text[end - 1] === '\t')) break; + if (char === ':' && FLOW_SEPARATORS.includes(text[end + 1] ?? ' ')) break; + end++; + } + return end; +} + +function readFlowValue(text: string, start: number, no: number, depth: number): { value: unknown; end: number } { const index = skipSpaces(text, start); const char = text[index]; if (char === undefined) throw new FrontmatterError(UNCLOSED_FLOW, no); if (char === '[' || char === '{') return readFlow(text, index, no, depth + 1); if (char === '"' || char === "'") return readQuoted(text, index, no); assertFlowPlainStart(text, index, no); - let end = index; - while (end < text.length && !terminators.includes(text[end]!)) { - const current = text[end]; - const previous = text[end - 1]; - if (current === '#' && (previous === ' ' || previous === '\t')) break; - if (current === ':' && (text[end + 1] === undefined || text[end + 1] === ' ' || text[end + 1] === '\t')) { - throw new FrontmatterError('mappings inside flow sequences are not supported', no); - } - end++; - } + const end = scanFlowPlain(text, index); + if (text[end] === ':') throw new FrontmatterError('mappings inside flow collections are not supported; quote the value', no); const raw = text.slice(index, end).trim(); if (raw === '') throw new FrontmatterError('empty value in a flow collection', no); return { value: resolvePlain(raw, no), end }; diff --git a/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts index eb52699cd9..33870f8d19 100644 --- a/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts +++ b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts @@ -21,7 +21,8 @@ const ACCEPTED: readonly string[] = [ 'name: x\ndescription: d\nempty:\nquoted-empty: ""\nunicode: "日本語 ✓"\n', 'name: x\ndescription: d\nnested:\n level1:\n level2:\n - deep\n', 'name: x\ndescription: d\ntags:\n-\ta\n-\tb\n', - 'name: x\ndescription: d\nflow: [-a, ?b, :c]\nmap: {-k: v, ?k: w}\n' + 'name: x\ndescription: d\nflow: [-a, ?b, :c]\nmap: {-k: v, ?k: w}\n', + 'name: x\ndescription: d\nflow: [http://x.test/a, b:c, a#b]\nmap: {a:b: c, :k: v, http://x.test: 1, u: http://x.test, k#: v#}\n' ]; /** Valid YAML the parser deliberately rejects (it must fail closed, never misread). */ @@ -35,7 +36,16 @@ const REJECTED: readonly string[] = [ 'flow: [:]\n', 'map: {? k: v}\n', 'map: {!t k: v}\n', - 'map: {&a k: v}\n' + 'map: {&a k: v}\n', + 'flow: [x:]\n', + 'flow: [x:, y]\n', + 'flow: [x, y:]\n', + 'flow: [x::]\n', + 'flow: [x:y:]\n', + 'map: {k: [x:]}\n', + 'map: {[x]: y}\n', + 'map: {k: v, [x]: y}\n', + 'map: {{x: 1}: y}\n' ]; /** Invalid YAML: the yaml library rejects it, so the parser must too. */ @@ -54,7 +64,17 @@ const INVALID: readonly string[] = [ 'a: {|k: v}\n', 'a: x #c\n y\n', 'a: |\n \n x\n', - 'a: "\\U00110000"\n' + 'a: "\\U00110000"\n', + 'a: [x{y]\n', + 'a: [x}y]\n', + 'a: {k: x]y}\n', + 'a: {k: x[y}\n', + 'a: {k: x{y}\n', + 'a: {k: v:}\n', + 'a: {k: v:, j: w}\n', + 'a: {x #y: z}\n', + 'a: {x]: y}\n', + 'a: {x[: y}\n' ]; const OPTIONS = { version: '1.2', schema: 'core', uniqueKeys: true, merge: false } as const; From 77cbce77921ff1a287831c74125529ad6557a9e1 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 04:35:46 +0300 Subject: [PATCH 07/45] feat(core-internal): skills digests, URIs and entry validation Runtime-neutral UTF-8, base64 and SHA-256 helpers; skill URI rules (segment encoding, canonical form, containment, reference resolution, one code-unit order); a MIME table; and entry validation and building shared by the client and server. Every tsconfig that compiles core-internal sources maps @modelcontextprotocol/core/ext/skills. --- examples/client-quickstart/tsconfig.json | 3 + examples/server-quickstart/tsconfig.json | 3 + examples/shared/tsconfig.json | 3 + examples/tsconfig.json | 1 + .../core-internal/src/ext/skills/bytes.ts | 41 +++++++ .../core-internal/src/ext/skills/entry.ts | 94 +++++++++++++++ .../core-internal/src/ext/skills/index.ts | 15 +++ packages/core-internal/src/ext/skills/mime.ts | 39 ++++++ packages/core-internal/src/ext/skills/uri.ts | 76 ++++++++++++ .../test/ext/skills/bytes.test.ts | 24 ++++ .../test/ext/skills/entry.test.ts | 113 ++++++++++++++++++ .../core-internal/test/ext/skills/uri.test.ts | 59 +++++++++ packages/core-internal/tsconfig.json | 3 +- packages/middleware/express/tsconfig.json | 3 + packages/middleware/fastify/tsconfig.json | 3 + packages/middleware/hono/tsconfig.json | 3 + packages/middleware/node/tsconfig.json | 3 + packages/server-legacy/tsconfig.json | 1 + test/conformance/tsconfig.json | 3 + test/e2e/tsconfig.json | 3 + test/helpers/tsconfig.json | 3 + test/integration/tsconfig.json | 3 + 22 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 packages/core-internal/src/ext/skills/bytes.ts create mode 100644 packages/core-internal/src/ext/skills/entry.ts create mode 100644 packages/core-internal/src/ext/skills/mime.ts create mode 100644 packages/core-internal/src/ext/skills/uri.ts create mode 100644 packages/core-internal/test/ext/skills/bytes.test.ts create mode 100644 packages/core-internal/test/ext/skills/entry.test.ts create mode 100644 packages/core-internal/test/ext/skills/uri.test.ts diff --git a/examples/client-quickstart/tsconfig.json b/examples/client-quickstart/tsconfig.json index b9650ed2f8..d55d7f0b36 100644 --- a/examples/client-quickstart/tsconfig.json +++ b/examples/client-quickstart/tsconfig.json @@ -16,6 +16,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ] diff --git a/examples/server-quickstart/tsconfig.json b/examples/server-quickstart/tsconfig.json index 8866f024a3..f3e03d7d47 100644 --- a/examples/server-quickstart/tsconfig.json +++ b/examples/server-quickstart/tsconfig.json @@ -16,6 +16,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ] diff --git a/examples/shared/tsconfig.json b/examples/shared/tsconfig.json index 59ce27deee..4ad8069783 100644 --- a/examples/shared/tsconfig.json +++ b/examples/shared/tsconfig.json @@ -16,6 +16,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 9405854c1f..40da4390c3 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -24,6 +24,7 @@ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/index.ts" ], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], + "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/packages/core-internal/src/ext/skills/bytes.ts b/packages/core-internal/src/ext/skills/bytes.ts new file mode 100644 index 0000000000..8138f31f7e --- /dev/null +++ b/packages/core-internal/src/ext/skills/bytes.ts @@ -0,0 +1,41 @@ +const encoder = new TextEncoder(); + +/** UTF-8 bytes of `text`. */ +export function encodeUtf8(text: string): Uint8Array { + return encoder.encode(text); +} + +/** Decodes UTF-8 only if decoding loses nothing (a BOM is kept); otherwise `undefined`. */ +export function decodeUtf8Lossless(bytes: Uint8Array): string | undefined { + try { + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); + } catch { + return undefined; + } +} + +/** Standard base64 of `bytes`. */ +export function encodeBase64(bytes: Uint8Array): string { + let binary = ''; + const chunk = 0x80_00; + for (let offset = 0; offset < bytes.length; offset += chunk) { + binary += String.fromCodePoint(...bytes.subarray(offset, offset + chunk)); + } + return btoa(binary); +} + +/** Bytes of a standard base64 string. */ +export function decodeBase64(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) bytes[index] = binary.codePointAt(index)!; + return bytes; +} + +/** `sha256:` followed by the lowercase hex SHA-256 of `bytes` (WebCrypto, every runtime). */ +export async function sha256Digest(bytes: Uint8Array): Promise { + const hash = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', new Uint8Array(bytes))); + let hex = ''; + for (const byte of hash) hex += byte.toString(16).padStart(2, '0'); + return `sha256:${hex}`; +} diff --git a/packages/core-internal/src/ext/skills/entry.ts b/packages/core-internal/src/ext/skills/entry.ts new file mode 100644 index 0000000000..bc3d681cc9 --- /dev/null +++ b/packages/core-internal/src/ext/skills/entry.ts @@ -0,0 +1,94 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SkillSchema } from '@modelcontextprotocol/core/ext/skills'; + +import { decodeUtf8Lossless, sha256Digest } from './bytes'; +import { FrontmatterError, parseSkillMarkdown } from './frontmatter'; +import { canonicalUri, compareUris, isWithin, lastSegmentOf, skillRootUri } from './uri'; + +/** Agent Skills name rule: lowercase letters, digits and single hyphens (length checked separately). */ +export const SKILL_NAME_PATTERN = /^[\da-z]+(?:-[\da-z]+)*$/; + +const isStringMap = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.values(value as Record).every(item => typeof item === 'string'); + +/** Structural rules of the stable spec and Agent Skills. Empty array = valid. */ +export function validateSkillEntry(skill: Skill): string[] { + const problems: string[] = []; + const root = skillRootUri(skill.uri); + if (root === undefined) return [`uri "${skill.uri}" must be a URI ending in /SKILL.md`]; + const frontmatter = skill.frontmatter as Record; + const { name, description } = skill.frontmatter; + if (name.length === 0 || name.length > 64 || !SKILL_NAME_PATTERN.test(name)) { + problems.push(`frontmatter name "${name}" must be 1-64 lowercase letters, digits and single hyphens`); + } else if (lastSegmentOf(root) !== name) { + problems.push(`frontmatter name "${name}" must match the last path segment "${lastSegmentOf(root)}"`); + } + if (description.length === 0 || description.length > 1024) problems.push('frontmatter description must be 1-1024 characters'); + const compatibility = frontmatter['compatibility']; + if (compatibility !== undefined && (typeof compatibility !== 'string' || compatibility.length === 0 || compatibility.length > 500)) { + problems.push('frontmatter compatibility must be a string of 1-500 characters'); + } + if (frontmatter['metadata'] !== undefined && !isStringMap(frontmatter['metadata'])) { + problems.push('frontmatter metadata must map strings to strings'); + } + if (skill.resources !== 'dynamic') { + const self = canonicalUri(skill.uri); + const seen = new Set(); + let hasSelf = false; + for (const resource of skill.resources) { + const canonical = canonicalUri(resource.uri); + if (canonical === undefined) { + problems.push(`resource "${resource.uri}" is not a valid URI`); + continue; + } + if (seen.has(canonical)) problems.push(`resource "${resource.uri}" is listed more than once`); + seen.add(canonical); + if (canonical === self) hasSelf = true; + else if (!isWithin(root, resource.uri)) problems.push(`resource "${resource.uri}" is outside the skill directory ${root}`); + } + if (!hasSelf) problems.push(`resources must include ${skill.uri}`); + } + return problems; +} + +/** Schema check plus {@linkcode validateSkillEntry}, for values received from elsewhere. */ +export function skillEntryProblems(value: unknown): string[] { + const parsed = SkillSchema.safeParse(value); + if (!parsed.success) return parsed.error.issues.map(issue => `${issue.path.join('.') || '(entry)'}: ${issue.message}`); + return validateSkillEntry(parsed.data); +} + +export interface SkillFileInput { + uri: string; + bytes: Uint8Array; +} + +/** Builds an entry from a skill's files: parses `SKILL.md`, hashes every file, sorts by URI, validates. */ +export async function buildSkillEntry( + skillMdUri: string, + files: readonly SkillFileInput[] +): Promise<{ skill?: Skill; problems: string[] }> { + const manifest = files.find(file => file.uri === skillMdUri); + if (manifest === undefined) return { problems: ['SKILL.md is missing'] }; + const text = decodeUtf8Lossless(manifest.bytes); + if (text === undefined) return { problems: ['SKILL.md is not valid UTF-8'] }; + let frontmatter: Record; + try { + frontmatter = parseSkillMarkdown(text).frontmatter; + } catch (error) { + if (error instanceof FrontmatterError) return { problems: [`SKILL.md frontmatter: ${error.message}`] }; + throw error; + } + if (typeof frontmatter['name'] !== 'string' || typeof frontmatter['description'] !== 'string') { + return { problems: ['SKILL.md frontmatter must contain string "name" and "description"'] }; + } + const resources = await Promise.all( + files.map(async file => ({ uri: file.uri, digest: await sha256Digest(file.bytes), size: file.bytes.byteLength })) + ); + resources.sort((a, b) => compareUris(a.uri, b.uri)); + const skill = { uri: skillMdUri, frontmatter, resources } as Skill; + return { skill, problems: validateSkillEntry(skill) }; +} diff --git a/packages/core-internal/src/ext/skills/index.ts b/packages/core-internal/src/ext/skills/index.ts index 5bb9a19d4f..ff805489e8 100644 --- a/packages/core-internal/src/ext/skills/index.ts +++ b/packages/core-internal/src/ext/skills/index.ts @@ -1,4 +1,19 @@ // Internal helpers for the MCP Skills extension (SEP-2640), shared by the // client and server packages. Not part of any public entry point. +export { decodeBase64, decodeUtf8Lossless, encodeBase64, encodeUtf8, sha256Digest } from './bytes'; +export type { SkillFileInput } from './entry'; +export { buildSkillEntry, SKILL_NAME_PATTERN, skillEntryProblems, validateSkillEntry } from './entry'; export type { ParsedSkillMarkdown, SplitSkillMarkdown } from './frontmatter'; export { FrontmatterError, parseFrontmatterYaml, parseSkillMarkdown, splitFrontmatter } from './frontmatter'; +export { isTextualMimeType, mimeTypeFor } from './mime'; +export { + canonicalUri, + compareUris, + encodeSegment, + isRoundTripUri, + isWithin, + joinUri, + lastSegmentOf, + resolveSkillRef, + skillRootUri +} from './uri'; diff --git a/packages/core-internal/src/ext/skills/mime.ts b/packages/core-internal/src/ext/skills/mime.ts new file mode 100644 index 0000000000..bb756dfc33 --- /dev/null +++ b/packages/core-internal/src/ext/skills/mime.ts @@ -0,0 +1,39 @@ +const BY_EXTENSION: Readonly> = { + md: 'text/markdown', + markdown: 'text/markdown', + txt: 'text/plain', + json: 'application/json', + yaml: 'application/yaml', + yml: 'application/yaml', + py: 'text/x-python', + sh: 'application/x-sh', + bash: 'application/x-sh', + js: 'text/javascript', + mjs: 'text/javascript', + ts: 'text/typescript', + html: 'text/html', + css: 'text/css', + csv: 'text/csv', + xml: 'application/xml', + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + pdf: 'application/pdf' +}; + +const TEXTUAL = new Set(['application/json', 'application/yaml', 'application/x-sh', 'application/xml', 'image/svg+xml']); + +/** MIME type from the file extension; `application/octet-stream` when unknown. */ +export function mimeTypeFor(fileName: string): string { + const dot = fileName.lastIndexOf('.'); + if (dot <= 0) return 'application/octet-stream'; + return BY_EXTENSION[fileName.slice(dot + 1).toLowerCase()] ?? 'application/octet-stream'; +} + +/** True for types served as `text` when the bytes decode losslessly. */ +export function isTextualMimeType(mimeType: string): boolean { + return mimeType.startsWith('text/') || TEXTUAL.has(mimeType); +} diff --git a/packages/core-internal/src/ext/skills/uri.ts b/packages/core-internal/src/ext/skills/uri.ts new file mode 100644 index 0000000000..91c074f2de --- /dev/null +++ b/packages/core-internal/src/ext/skills/uri.ts @@ -0,0 +1,76 @@ +/** Percent-encodes one path segment; the result is stable under `new URL()`. */ +export function encodeSegment(segment: string): string { + return encodeURIComponent(segment); +} + +/** `base` + `/`-joined encoded segments. `base` is `scheme://` or a URI without a trailing slash. */ +export function joinUri(base: string, segments: readonly string[]): string { + const prefix = base.endsWith('://') ? base : `${base.replace(/\/+$/, '')}/`; + return prefix + segments.map(segment => encodeSegment(segment)).join('/'); +} + +/** WHATWG-serialised form of `uri`, or `undefined` if it does not parse. */ +export function canonicalUri(uri: string): string | undefined { + try { + return new URL(uri).href; + } catch { + return undefined; + } +} + +/** True when `uri` is already in canonical form. */ +export function isRoundTripUri(uri: string): boolean { + return canonicalUri(uri) === uri; +} + +const MANIFEST_SUFFIX = '/SKILL.md'; + +/** The skill's root directory URI (no trailing slash), or `undefined` if `skillMdUri` is not a `…/SKILL.md` URI. */ +export function skillRootUri(skillMdUri: string): string | undefined { + const canonical = canonicalUri(skillMdUri); + if (canonical === undefined || !canonical.endsWith(MANIFEST_SUFFIX)) return undefined; + const root = canonical.slice(0, -MANIFEST_SUFFIX.length); + return root.endsWith('://') ? undefined : root; +} + +function safeDecode(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +/** The decoded last segment of a directory URI (the authority when the path is empty). */ +export function lastSegmentOf(rootUri: string): string { + const url = new URL(rootUri); + const path = url.pathname; + return safeDecode(path === '' || path === '/' ? url.host : path.slice(path.lastIndexOf('/') + 1)); +} + +/** True when `uri`, once normalised, is strictly inside `rootUri` with no empty, `.` or `..` segments. */ +export function isWithin(rootUri: string, uri: string): boolean { + const canonical = canonicalUri(uri); + if (canonical === undefined || !canonical.startsWith(`${rootUri}/`)) return false; + return canonical + .slice(rootUri.length + 1) + .split('/') + .every(segment => { + const decoded = safeDecode(segment); + return decoded !== '' && decoded !== '.' && decoded !== '..'; + }); +} + +/** Resolves `ref` (default `SKILL.md`) against the skill's root, as a browser resolves a link. */ +export function resolveSkillRef(skillMdUri: string, ref?: string): string { + const root = skillRootUri(skillMdUri); + if (root === undefined) throw new TypeError(`${skillMdUri} is not a SKILL.md URI`); + return new URL(ref ?? 'SKILL.md', `${root}/`).href; +} + +/** Code-unit order of two URIs: the one order for entries, directory children and cursors. */ +export function compareUris(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/core-internal/test/ext/skills/bytes.test.ts b/packages/core-internal/test/ext/skills/bytes.test.ts new file mode 100644 index 0000000000..729cc66427 --- /dev/null +++ b/packages/core-internal/test/ext/skills/bytes.test.ts @@ -0,0 +1,24 @@ +import { createHash } from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; + +import { decodeBase64, decodeUtf8Lossless, encodeBase64, encodeUtf8, sha256Digest } from '../../../src/ext/skills/bytes'; + +describe('bytes', () => { + it('round-trips base64 for every byte value', () => { + const bytes = Uint8Array.from({ length: 256 }, (_, index) => index); + expect(decodeBase64(encodeBase64(bytes))).toEqual(bytes); + }); + + it('decodes UTF-8 losslessly, keeping a BOM, and rejects invalid UTF-8', () => { + const withBom = Uint8Array.from([0xef, 0xbb, 0xbf, 0x61]); + expect(decodeUtf8Lossless(withBom)).toBe('a'); + expect(encodeUtf8(decodeUtf8Lossless(withBom)!)).toEqual(withBom); + expect(decodeUtf8Lossless(Uint8Array.from([0xff, 0xfe]))).toBeUndefined(); + }); + + it('computes sha256 digests identical to node:crypto', async () => { + const bytes = encodeUtf8('hello skills'); + expect(await sha256Digest(bytes)).toBe(`sha256:${createHash('sha256').update(bytes).digest('hex')}`); + }); +}); diff --git a/packages/core-internal/test/ext/skills/entry.test.ts b/packages/core-internal/test/ext/skills/entry.test.ts new file mode 100644 index 0000000000..6d8f8dda83 --- /dev/null +++ b/packages/core-internal/test/ext/skills/entry.test.ts @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto'; + +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { describe, expect, it } from 'vitest'; + +import { encodeUtf8 } from '../../../src/ext/skills/bytes'; +import { buildSkillEntry, skillEntryProblems, validateSkillEntry } from '../../../src/ext/skills/entry'; + +const digest = `sha256:${'a'.repeat(64)}`; +const entry = (overrides: Partial = {}): Skill => ({ + uri: 'skill://acme/refunds/SKILL.md', + frontmatter: { name: 'refunds', description: 'Refund workflow.' }, + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/references/policy.md', digest, size: 1 } + ], + ...overrides +}); + +describe('validateSkillEntry', () => { + it('accepts a valid entry and a dynamic one', () => { + expect(validateSkillEntry(entry())).toEqual([]); + expect(validateSkillEntry(entry({ resources: 'dynamic' }))).toEqual([]); + }); + + it.each([ + ['a URI not ending in /SKILL.md', entry({ uri: 'skill://acme/refunds/README.md' }), /must be a URI ending in \/SKILL\.md/], + [ + 'a name that does not match its folder', + entry({ frontmatter: { name: 'refund', description: 'd' } }), + /must match the last path segment/ + ], + ['an invalid name', entry({ frontmatter: { name: 'Refunds', description: 'd' } }), /lowercase/], + ['an empty description', entry({ frontmatter: { name: 'refunds', description: '' } }), /description/], + ['a non-string metadata value', entry({ frontmatter: { name: 'refunds', description: 'd', metadata: { v: 1 } } }), /metadata/], + ['a missing SKILL.md entry', entry({ resources: [{ uri: 'skill://acme/refunds/x.md', digest, size: 1 }] }), /must include/], + [ + 'a file outside the skill', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/other/x.md', digest, size: 1 } + ] + }), + /outside the skill/ + ], + [ + 'an escaping file', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/../other/x.md', digest, size: 1 } + ] + }), + /outside the skill/ + ], + [ + 'a duplicate file', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 } + ] + }), + /more than once/ + ] + ])('reports %s', (_label, skill, problem) => { + expect(validateSkillEntry(skill).join('\n')).toMatch(problem); + }); + + it('skillEntryProblems also catches schema violations', () => { + expect(skillEntryProblems({ uri: 'skill://a/SKILL.md' }).length).toBeGreaterThan(0); + expect(skillEntryProblems(entry())).toEqual([]); + }); +}); + +describe('buildSkillEntry', () => { + it('builds a valid entry with digests computed from the bytes', async () => { + const manifest = encodeUtf8('---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\nBody\r\n'); + const policy = encodeUtf8('# Policy\n'); + const { skill, problems } = await buildSkillEntry('skill://acme/refunds/SKILL.md', [ + { uri: 'skill://acme/refunds/SKILL.md', bytes: manifest }, + { uri: 'skill://acme/refunds/references/policy.md', bytes: policy } + ]); + expect(problems).toEqual([]); + expect(skill?.frontmatter).toEqual({ name: 'refunds', description: 'Refund workflow.' }); + expect(skill?.resources).toEqual([ + { + uri: 'skill://acme/refunds/SKILL.md', + digest: `sha256:${createHash('sha256').update(manifest).digest('hex')}`, + size: manifest.byteLength + }, + { + uri: 'skill://acme/refunds/references/policy.md', + digest: `sha256:${createHash('sha256').update(policy).digest('hex')}`, + size: policy.byteLength + } + ]); + }); + + it('reports invalid UTF-8, bad frontmatter and missing fields', async () => { + const uri = 'skill://acme/refunds/SKILL.md'; + expect(await buildSkillEntry(uri, [{ uri, bytes: Uint8Array.from([0xff]) }])).toEqual({ + problems: ['SKILL.md is not valid UTF-8'] + }); + expect(await buildSkillEntry(uri, [{ uri, bytes: encodeUtf8('---\nname: &a refunds\n---\n') }])).toMatchObject({ + problems: [expect.stringMatching(/frontmatter/)] + }); + expect(await buildSkillEntry(uri, [{ uri, bytes: encodeUtf8('---\nname: refunds\n---\n') }])).toMatchObject({ + problems: [expect.stringMatching(/"name" and "description"/)] + }); + }); +}); diff --git a/packages/core-internal/test/ext/skills/uri.test.ts b/packages/core-internal/test/ext/skills/uri.test.ts new file mode 100644 index 0000000000..5b12413db8 --- /dev/null +++ b/packages/core-internal/test/ext/skills/uri.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalUri, + compareUris, + isRoundTripUri, + isWithin, + joinUri, + lastSegmentOf, + resolveSkillRef, + skillRootUri +} from '../../../src/ext/skills/uri'; + +describe('uri helpers', () => { + it('joins and percent-encodes segments so they survive new URL()', () => { + const uri = joinUri('skill://', ['acme', 'refunds', 'templates', 'Purchase Order.md']); + expect(uri).toBe('skill://acme/refunds/templates/Purchase%20Order.md'); + expect(isRoundTripUri(uri)).toBe(true); + expect(joinUri('github://owner/repo/skills/', ['x', 'SKILL.md'])).toBe('github://owner/repo/skills/x/SKILL.md'); + }); + + it('finds the skill root and its last segment', () => { + expect(skillRootUri('skill://acme/billing/refunds/SKILL.md')).toBe('skill://acme/billing/refunds'); + expect(skillRootUri('skill://git-workflow/SKILL.md')).toBe('skill://git-workflow'); + expect(skillRootUri('skill://git-workflow/README.md')).toBeUndefined(); + expect(lastSegmentOf('skill://acme/billing/refunds')).toBe('refunds'); + expect(lastSegmentOf('skill://git-workflow')).toBe('git-workflow'); + }); + + it('checks containment after normalisation', () => { + expect(isWithin('skill://a', 'skill://a/x/y.md')).toBe(true); + expect(isWithin('skill://a', 'skill://ab/x.md')).toBe(false); + expect(isWithin('skill://a/b', 'skill://a/b/../c')).toBe(false); + expect(isWithin('skill://a/b', 'skill://a/b/%2e%2e/c')).toBe(false); + }); + + it('resolves references against the skill root as the spec examples do', () => { + const skill = 'skill://acme/billing/refunds/SKILL.md'; + expect(resolveSkillRef(skill)).toBe(skill); + expect(resolveSkillRef(skill, 'references/GUIDE.md')).toBe('skill://acme/billing/refunds/references/GUIDE.md'); + expect(resolveSkillRef(skill, './templates/Purchase Order.md')).toBe('skill://acme/billing/refunds/templates/Purchase%20Order.md'); + expect(resolveSkillRef(skill, '../other/SKILL.md')).toBe('skill://acme/billing/other/SKILL.md'); + expect(resolveSkillRef(skill, 'skill://acme/billing/refunds/scripts/run.sh')).toBe('skill://acme/billing/refunds/scripts/run.sh'); + expect(() => resolveSkillRef('skill://acme/README.md', 'x')).toThrow(TypeError); + }); + + it('canonicalUri returns undefined for non-URIs', () => { + expect(canonicalUri('not a uri')).toBeUndefined(); + }); + + it('orders URIs by code unit, whatever the locale', () => { + expect(['skill://b', 'skill://a/SKILL.md', 'skill://B', 'skill://a/b'].toSorted((a, b) => compareUris(a, b))).toEqual([ + 'skill://B', + 'skill://a/SKILL.md', + 'skill://a/b', + 'skill://b' + ]); + }); +}); diff --git a/packages/core-internal/tsconfig.json b/packages/core-internal/tsconfig.json index 38faa24f6e..d46f187ffb 100644 --- a/packages/core-internal/tsconfig.json +++ b/packages/core-internal/tsconfig.json @@ -7,7 +7,8 @@ "*": ["./*"], "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], - "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"] + "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], + "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"] } } } diff --git a/packages/middleware/express/tsconfig.json b/packages/middleware/express/tsconfig.json index 5cf75f3ba6..01f4447bdb 100644 --- a/packages/middleware/express/tsconfig.json +++ b/packages/middleware/express/tsconfig.json @@ -13,6 +13,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ] diff --git a/packages/middleware/fastify/tsconfig.json b/packages/middleware/fastify/tsconfig.json index 7e06eb27be..de34390e99 100644 --- a/packages/middleware/fastify/tsconfig.json +++ b/packages/middleware/fastify/tsconfig.json @@ -12,6 +12,9 @@ ], "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" + ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ] } } diff --git a/packages/middleware/hono/tsconfig.json b/packages/middleware/hono/tsconfig.json index 5cf75f3ba6..01f4447bdb 100644 --- a/packages/middleware/hono/tsconfig.json +++ b/packages/middleware/hono/tsconfig.json @@ -13,6 +13,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ] diff --git a/packages/middleware/node/tsconfig.json b/packages/middleware/node/tsconfig.json index d0f212f4fb..1471f487d7 100644 --- a/packages/middleware/node/tsconfig.json +++ b/packages/middleware/node/tsconfig.json @@ -11,6 +11,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/packages/server-legacy/tsconfig.json b/packages/server-legacy/tsconfig.json index e2886239b8..832ad0ea9e 100644 --- a/packages/server-legacy/tsconfig.json +++ b/packages/server-legacy/tsconfig.json @@ -7,6 +7,7 @@ "*": ["./*"], "@modelcontextprotocol/core-internal": ["./node_modules/@modelcontextprotocol/core-internal/src/index.ts"], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], + "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], "@modelcontextprotocol/core-internal/public": ["./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts"] } } diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index a7617960f8..7e6efcdfba 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -9,6 +9,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json index c4e5ba6f4a..fbf706b21a 100644 --- a/test/e2e/tsconfig.json +++ b/test/e2e/tsconfig.json @@ -9,6 +9,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/test/helpers/tsconfig.json b/test/helpers/tsconfig.json index 781d610820..5e6d0ea6d0 100644 --- a/test/helpers/tsconfig.json +++ b/test/helpers/tsconfig.json @@ -9,6 +9,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json index 2b016fd288..02b5a5f54e 100644 --- a/test/integration/tsconfig.json +++ b/test/integration/tsconfig.json @@ -9,6 +9,9 @@ "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], From 9c25b726dff065f3a600f7a44f9e9c09ff622eac Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 04:47:49 +0300 Subject: [PATCH 08/45] fix(core-internal): tighten skill URI containment and root rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isWithin rejects segments that decode to a '/' or NUL, and skillRootUri rejects roots that end in '/' (an empty segment). Tests restore the  escapes and exercise the resource sort. --- .../core-internal/src/ext/skills/entry.ts | 2 +- packages/core-internal/src/ext/skills/uri.ts | 16 +++++++++---- .../test/ext/skills/bytes.test.ts | 2 +- .../test/ext/skills/entry.test.ts | 24 +++++++++++++++---- .../core-internal/test/ext/skills/uri.test.ts | 12 ++++++++++ 5 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/core-internal/src/ext/skills/entry.ts b/packages/core-internal/src/ext/skills/entry.ts index bc3d681cc9..4a625f8150 100644 --- a/packages/core-internal/src/ext/skills/entry.ts +++ b/packages/core-internal/src/ext/skills/entry.ts @@ -18,7 +18,7 @@ const isStringMap = (value: unknown): boolean => export function validateSkillEntry(skill: Skill): string[] { const problems: string[] = []; const root = skillRootUri(skill.uri); - if (root === undefined) return [`uri "${skill.uri}" must be a URI ending in /SKILL.md`]; + if (root === undefined) return [`uri "${skill.uri}" must be a URI ending in /SKILL.md inside a named directory`]; const frontmatter = skill.frontmatter as Record; const { name, description } = skill.frontmatter; if (name.length === 0 || name.length > 64 || !SKILL_NAME_PATTERN.test(name)) { diff --git a/packages/core-internal/src/ext/skills/uri.ts b/packages/core-internal/src/ext/skills/uri.ts index 91c074f2de..c28c8c9989 100644 --- a/packages/core-internal/src/ext/skills/uri.ts +++ b/packages/core-internal/src/ext/skills/uri.ts @@ -25,12 +25,16 @@ export function isRoundTripUri(uri: string): boolean { const MANIFEST_SUFFIX = '/SKILL.md'; -/** The skill's root directory URI (no trailing slash), or `undefined` if `skillMdUri` is not a `…/SKILL.md` URI. */ +/** + * The skill's root directory URI (no trailing slash), or `undefined` if `skillMdUri` is not a `…/SKILL.md` URI + * directly inside a named directory. + */ export function skillRootUri(skillMdUri: string): string | undefined { const canonical = canonicalUri(skillMdUri); if (canonical === undefined || !canonical.endsWith(MANIFEST_SUFFIX)) return undefined; const root = canonical.slice(0, -MANIFEST_SUFFIX.length); - return root.endsWith('://') ? undefined : root; + // `scheme:/` or `scheme://` (no directory) and `…//SKILL.md` (an empty last segment) name no directory. + return root.endsWith('/') ? undefined : root; } function safeDecode(segment: string): string { @@ -48,7 +52,11 @@ export function lastSegmentOf(rootUri: string): string { return safeDecode(path === '' || path === '/' ? url.host : path.slice(path.lastIndexOf('/') + 1)); } -/** True when `uri`, once normalised, is strictly inside `rootUri` with no empty, `.` or `..` segments. */ +/** + * True when `uri`, once normalised, is strictly inside `rootUri` and every segment below it decodes to a plain + * name: not empty, `.` or `..`, and free of `/` and NUL. An encoded `%2F` would otherwise carry traversal inside + * one segment. A backslash is allowed: it is legal in POSIX names and {@linkcode joinUri} encodes it as `%5C`. + */ export function isWithin(rootUri: string, uri: string): boolean { const canonical = canonicalUri(uri); if (canonical === undefined || !canonical.startsWith(`${rootUri}/`)) return false; @@ -57,7 +65,7 @@ export function isWithin(rootUri: string, uri: string): boolean { .split('/') .every(segment => { const decoded = safeDecode(segment); - return decoded !== '' && decoded !== '.' && decoded !== '..'; + return decoded !== '' && decoded !== '.' && decoded !== '..' && !decoded.includes('/') && !decoded.includes('\0'); }); } diff --git a/packages/core-internal/test/ext/skills/bytes.test.ts b/packages/core-internal/test/ext/skills/bytes.test.ts index 729cc66427..93c4dbbc8d 100644 --- a/packages/core-internal/test/ext/skills/bytes.test.ts +++ b/packages/core-internal/test/ext/skills/bytes.test.ts @@ -12,7 +12,7 @@ describe('bytes', () => { it('decodes UTF-8 losslessly, keeping a BOM, and rejects invalid UTF-8', () => { const withBom = Uint8Array.from([0xef, 0xbb, 0xbf, 0x61]); - expect(decodeUtf8Lossless(withBom)).toBe('a'); + expect(decodeUtf8Lossless(withBom)).toBe('\uFEFFa'); expect(encodeUtf8(decodeUtf8Lossless(withBom)!)).toEqual(withBom); expect(decodeUtf8Lossless(Uint8Array.from([0xff, 0xfe]))).toBeUndefined(); }); diff --git a/packages/core-internal/test/ext/skills/entry.test.ts b/packages/core-internal/test/ext/skills/entry.test.ts index 6d8f8dda83..71b3fa46e6 100644 --- a/packages/core-internal/test/ext/skills/entry.test.ts +++ b/packages/core-internal/test/ext/skills/entry.test.ts @@ -25,6 +25,11 @@ describe('validateSkillEntry', () => { it.each([ ['a URI not ending in /SKILL.md', entry({ uri: 'skill://acme/refunds/README.md' }), /must be a URI ending in \/SKILL\.md/], + [ + 'an empty segment before SKILL.md', + entry({ uri: 'skill://acme//SKILL.md', frontmatter: { name: 'acme', description: 'd' }, resources: 'dynamic' }), + /must be a URI ending in \/SKILL\.md/ + ], [ 'a name that does not match its folder', entry({ frontmatter: { name: 'refund', description: 'd' } }), @@ -54,6 +59,16 @@ describe('validateSkillEntry', () => { }), /outside the skill/ ], + [ + 'a file whose segment decodes to a traversal', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/x%2F..%2F..%2Fother%2Fx.md', digest, size: 1 } + ] + }), + /outside the skill/ + ], [ 'a duplicate file', entry({ @@ -75,12 +90,13 @@ describe('validateSkillEntry', () => { }); describe('buildSkillEntry', () => { - it('builds a valid entry with digests computed from the bytes', async () => { - const manifest = encodeUtf8('---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\nBody\r\n'); + it('builds a valid entry with digests computed from the bytes, sorted by URI', async () => { + const manifest = encodeUtf8('\uFEFF---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\nBody\r\n'); const policy = encodeUtf8('# Policy\n'); + // Reverse code-unit order on purpose, so the output order proves the sort. const { skill, problems } = await buildSkillEntry('skill://acme/refunds/SKILL.md', [ - { uri: 'skill://acme/refunds/SKILL.md', bytes: manifest }, - { uri: 'skill://acme/refunds/references/policy.md', bytes: policy } + { uri: 'skill://acme/refunds/references/policy.md', bytes: policy }, + { uri: 'skill://acme/refunds/SKILL.md', bytes: manifest } ]); expect(problems).toEqual([]); expect(skill?.frontmatter).toEqual({ name: 'refunds', description: 'Refund workflow.' }); diff --git a/packages/core-internal/test/ext/skills/uri.test.ts b/packages/core-internal/test/ext/skills/uri.test.ts index 5b12413db8..95e6a2e88c 100644 --- a/packages/core-internal/test/ext/skills/uri.test.ts +++ b/packages/core-internal/test/ext/skills/uri.test.ts @@ -23,6 +23,9 @@ describe('uri helpers', () => { expect(skillRootUri('skill://acme/billing/refunds/SKILL.md')).toBe('skill://acme/billing/refunds'); expect(skillRootUri('skill://git-workflow/SKILL.md')).toBe('skill://git-workflow'); expect(skillRootUri('skill://git-workflow/README.md')).toBeUndefined(); + expect(skillRootUri('skill://acme//SKILL.md')).toBeUndefined(); + expect(skillRootUri('skill://acme/refunds//SKILL.md')).toBeUndefined(); + expect(skillRootUri('skill://SKILL.md')).toBeUndefined(); expect(lastSegmentOf('skill://acme/billing/refunds')).toBe('refunds'); expect(lastSegmentOf('skill://git-workflow')).toBe('git-workflow'); }); @@ -34,6 +37,15 @@ describe('uri helpers', () => { expect(isWithin('skill://a/b', 'skill://a/b/%2e%2e/c')).toBe(false); }); + it('rejects segments that decode to a slash or NUL, but keeps backslashes', () => { + expect(isWithin('skill://a/b', 'skill://a/b/x%2F..%2F..%2Fetc%2Fpasswd')).toBe(false); + expect(isWithin('skill://a/b', 'skill://a/b/x%2fy.md')).toBe(false); + expect(isWithin('skill://a/b', 'skill://a/b/x%00y.md')).toBe(false); + const backslash = joinUri('skill://a/b', ['dir\\name.md']); + expect(backslash).toBe('skill://a/b/dir%5Cname.md'); + expect(isWithin('skill://a/b', backslash)).toBe(true); + }); + it('resolves references against the skill root as the spec examples do', () => { const skill = 'skill://acme/billing/refunds/SKILL.md'; expect(resolveSkillRef(skill)).toBe(skill); From faaa89066a477a030797d4c84ea5ae5f7af51fab Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 04:55:38 +0300 Subject: [PATCH 09/45] feat(server): SkillsExtension and SkillSource One reusable, extension-shaped object per catalog: attach(mcp) declares io.modelcontextprotocol/skills, registers a skill:// resource template and the skills/list, skills/get and resources/directory/read handlers. Entries from custom sources are validated; caching fields only on 2026-07-28. Replaces installSkills from #2818. Co-authored-by: Matt Carey --- packages/server/src/ext/skills/errors.ts | 19 ++ packages/server/src/ext/skills/extension.ts | 183 ++++++++++++++ packages/server/src/ext/skills/files.ts | 8 + packages/server/src/ext/skills/index.ts | 180 +++----------- packages/server/src/ext/skills/internal.ts | 4 + packages/server/src/ext/skills/skillEntry.ts | 28 +++ packages/server/src/ext/skills/types.ts | 44 ++++ .../server/test/ext/skills/extension.test.ts | 228 ++++++++++++++++++ packages/server/test/ext/skills/harness.ts | 75 ++++++ .../test/ext/skills/installSkills.test.ts | 164 ------------- 10 files changed, 617 insertions(+), 316 deletions(-) create mode 100644 packages/server/src/ext/skills/errors.ts create mode 100644 packages/server/src/ext/skills/extension.ts create mode 100644 packages/server/src/ext/skills/files.ts create mode 100644 packages/server/src/ext/skills/internal.ts create mode 100644 packages/server/src/ext/skills/skillEntry.ts create mode 100644 packages/server/src/ext/skills/types.ts create mode 100644 packages/server/test/ext/skills/extension.test.ts create mode 100644 packages/server/test/ext/skills/harness.ts delete mode 100644 packages/server/test/ext/skills/installSkills.test.ts diff --git a/packages/server/src/ext/skills/errors.ts b/packages/server/src/ext/skills/errors.ts new file mode 100644 index 0000000000..f71f868dda --- /dev/null +++ b/packages/server/src/ext/skills/errors.ts @@ -0,0 +1,19 @@ +import type { SkillWarning } from './types'; + +/** Thrown when a skill catalog cannot be built: storage failures always, skill problems under `strict`. */ +export class SkillCatalogError extends Error { + readonly problems: readonly SkillWarning[]; + + constructor(problems: readonly SkillWarning[]) { + const lines = problems.map(problem => ` ${problem.path}: ${problem.message}`).join('\n'); + super(`${problems.length} skill problem${problems.length === 1 ? '' : 's'}:\n${lines}`); + this.name = 'SkillCatalogError'; + this.problems = problems; + } +} + +/** Default warning sink: stderr, so it never corrupts a stdio transport. */ +export function defaultWarn(warning: SkillWarning): void { + // eslint-disable-next-line no-console -- the documented default sink for skills warnings + console.warn(`[mcp-sdk] skills: ${warning.path}: ${warning.message}`); +} diff --git a/packages/server/src/ext/skills/extension.ts b/packages/server/src/ext/skills/extension.ts new file mode 100644 index 0000000000..8fd4b3161f --- /dev/null +++ b/packages/server/src/ext/skills/extension.ts @@ -0,0 +1,183 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { + GetSkillRequestParamsSchema, + ListSkillsRequestParamsSchema, + ReadResourceDirectoryRequestParamsSchema, + RESOURCES_DIRECTORY_READ_METHOD, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD +} from '@modelcontextprotocol/core/ext/skills'; +import type { CacheHint, ListResourcesResult, ReadResourceResult, ServerContext } from '@modelcontextprotocol/core-internal'; +import { + assertValidCacheHint, + canonicalUri, + encodeBase64, + isModernProtocolVersion, + ProtocolError, + ProtocolErrorCode, + ResourceNotFoundError, + skillEntryProblems +} from '@modelcontextprotocol/core-internal'; + +import type { McpServer } from '../../server/mcp'; +import { ResourceTemplate } from '../../server/mcp'; +import type { Server } from '../../server/server'; +import { defaultWarn } from './errors'; +import { trustedSources } from './internal'; +import type { SkillReadResult, SkillSource, SkillSourceOptions, SkillWarning } from './types'; + +const SCHEME_URI = /^[a-z][\d+.a-z-]*:\/\//i; + +function describeEntry(value: unknown): string { + const uri = (value as { uri?: unknown } | null)?.uri; + return typeof uri === 'string' ? uri : '(entry without a uri)'; +} + +function toContents(uri: string, result: SkillReadResult): ReadResourceResult['contents'][number] { + const mimeType = result.mimeType === undefined ? {} : { mimeType: result.mimeType }; + return 'text' in result ? { uri, ...mimeType, text: result.text } : { uri, ...mimeType, blob: encodeBase64(result.bytes) }; +} + +/** + * The MCP Skills extension (SEP-2640) for one catalog of skills. One instance + * serves any number of servers: call {@linkcode SkillsExtension.attach} on each + * (before connecting). Build it from a {@linkcode SkillSource}, or with + * `SkillsExtension.load(files)` from storage. + */ +export class SkillsExtension { + readonly id = SKILLS_EXTENSION_ID; + readonly #source: SkillSource; + readonly #trusted: boolean; + readonly #baseRoot: string; + readonly #cacheHint: CacheHint | undefined; + readonly #cache: { ttlMs: number; cacheScope: 'public' | 'private' }; + readonly #strict: boolean; + readonly #warn: (warning: SkillWarning) => void; + readonly #attached = new WeakSet(); + + constructor(options: SkillSourceOptions) { + if (options.cacheHint !== undefined) assertValidCacheHint(options.cacheHint, 'skills cacheHint'); + const baseUri = options.baseUri ?? 'skill://'; + if (!SCHEME_URI.test(baseUri)) throw new TypeError(`baseUri must look like "scheme://…" (got ${JSON.stringify(baseUri)})`); + this.#baseRoot = baseUri.endsWith('://') ? baseUri : `${baseUri.replace(/\/+$/, '')}/`; + this.#source = options.source; + this.#trusted = trustedSources.has(options.source); + this.#cacheHint = options.cacheHint; + this.#cache = { ttlMs: options.cacheHint?.ttlMs ?? 0, cacheScope: options.cacheHint?.cacheScope ?? 'private' }; + this.#strict = options.strict ?? false; + this.#warn = options.onWarning ?? defaultWarn; + } + + /** + * Serves these skills on `mcp`: declares the capability, registers the + * skill resource template and the skills methods. Must run before + * connecting. Calling it again for the same server does nothing. + */ + attach(mcp: McpServer): void { + if (this.#attached.has(mcp)) return; + if (mcp.isConnected()) throw new Error('attach skills before connecting the server'); + const server = mcp.server; + const directoryRead = this.#source.readDirectory !== undefined; + server.registerCapabilities({ extensions: { [SKILLS_EXTENSION_ID]: directoryRead ? { directoryRead: true } : {} } }); + const list = this.#source.listResources === undefined ? undefined : (ctx: ServerContext) => this.#listResources(ctx); + mcp.registerResource( + SKILLS_EXTENSION_ID, + new ResourceTemplate(`${this.#baseRoot}{+path}`, { list }), + this.#cacheHint === undefined ? {} : { cacheHint: this.#cacheHint }, + (uri, _variables, ctx) => this.#read(uri, ctx) + ); + server.setRequestHandler(SKILLS_LIST_METHOD, { params: ListSkillsRequestParamsSchema }, (params, ctx) => + this.#list(server, params.cursor, ctx) + ); + server.setRequestHandler(SKILLS_GET_METHOD, { params: GetSkillRequestParamsSchema }, (params, ctx) => + this.#get(server, params.uri, ctx) + ); + if (directoryRead) { + server.setRequestHandler(RESOURCES_DIRECTORY_READ_METHOD, { params: ReadResourceDirectoryRequestParamsSchema }, (params, ctx) => + this.#readDirectory(server, params.uri, params.cursor, ctx) + ); + } + this.#attached.add(mcp); + this.afterAttach(mcp); + } + + /** Called once per server after {@linkcode SkillsExtension.attach} registered everything. */ + protected afterAttach(_mcp: McpServer): void {} + + #cacheFields(server: Server): { ttlMs?: number; cacheScope?: 'public' | 'private' } { + return isModernProtocolVersion(server.getNegotiatedProtocolVersion() ?? '') ? { ...this.#cache } : {}; + } + + #keepValid(skills: readonly Skill[]): Skill[] { + if (this.#trusted) return [...skills]; + const kept: Skill[] = []; + for (const candidate of skills) { + const problems = skillEntryProblems(candidate); + if (problems.length === 0) { + kept.push(candidate); + continue; + } + const message = `invalid skill entry from the source: ${problems.join('; ')}`; + if (this.#strict) throw new ProtocolError(ProtocolErrorCode.InternalError, `${describeEntry(candidate)}: ${message}`); + this.#warn({ path: describeEntry(candidate), message: `${message} (dropped from skills/list)` }); + } + return kept; + } + + async #list(server: Server, cursor: string | undefined, ctx: ServerContext) { + const page = await this.#source.list(cursor === undefined ? {} : { cursor }, ctx); + return { + skills: this.#keepValid(page.skills), + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + ...this.#cacheFields(server) + }; + } + + async #get(server: Server, uri: string, ctx: ServerContext) { + const skill = await this.#source.get(uri, ctx); + if (skill === undefined) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `No skill is served at ${uri}`); + if (!this.#trusted) { + const problems = skillEntryProblems(skill); + if (problems.length > 0) { + const message = `invalid skill entry from the source: ${problems.join('; ')}`; + this.#warn({ path: uri, message }); + throw new ProtocolError(ProtocolErrorCode.InternalError, message); + } + } + if (canonicalUri(skill.uri) !== canonicalUri(uri)) { + throw new ProtocolError(ProtocolErrorCode.InternalError, `the skill source returned ${skill.uri} for ${uri}`); + } + return { skill, ...this.#cacheFields(server) }; + } + + async #read(uri: URL, ctx: ServerContext): Promise { + const result = await this.#source.read(uri.href, ctx); + if (result === undefined) throw new ResourceNotFoundError(uri.href); + return { contents: [toContents(uri.href, result)] }; + } + + async #readDirectory(server: Server, uri: string, cursor: string | undefined, ctx: ServerContext) { + const page = await this.#source.readDirectory!(uri, cursor === undefined ? {} : { cursor }, ctx); + if (page === undefined) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `${uri} is not a directory resource`); + return { + resources: page.resources, + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + ...this.#cacheFields(server) + }; + } + + async #listResources(ctx: ServerContext): Promise { + const resources = await this.#source.listResources!(ctx); + const kept = []; + for (const resource of resources) { + if (canonicalUri(resource.uri)?.startsWith(this.#baseRoot)) kept.push(resource); + else + this.#warn({ + path: resource.uri, + message: `listResources returned a URI outside ${this.#baseRoot}; dropped from resources/list` + }); + } + return { resources: kept }; + } +} diff --git a/packages/server/src/ext/skills/files.ts b/packages/server/src/ext/skills/files.ts new file mode 100644 index 0000000000..daed985bb3 --- /dev/null +++ b/packages/server/src/ext/skills/files.ts @@ -0,0 +1,8 @@ +/** Splits a relative `/`-separated path, rejecting `..` and empty paths. */ +export function splitRelativePath(path: string, label: string): string[] { + const segments = path.split('/').filter(segment => segment !== '' && segment !== '.'); + if (segments.length === 0 || segments.includes('..')) { + throw new TypeError(`${label} must be a relative path without ".." (got ${JSON.stringify(path)})`); + } + return segments; +} diff --git a/packages/server/src/ext/skills/index.ts b/packages/server/src/ext/skills/index.ts index d8eb2fbbed..d7ccfa9a41 100644 --- a/packages/server/src/ext/skills/index.ts +++ b/packages/server/src/ext/skills/index.ts @@ -1,157 +1,33 @@ // @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, +// Server side of the MCP Skills extension (SEP-2640). Adapted, with +// attribution, from the Apache-2.0 `@olaservo/ext-skills` prototype +// (modelcontextprotocol/ext-skills#71), typescript-sdk#2818 and #2972. + +export { SkillCatalogError } from './errors'; +export { SkillsExtension } from './extension'; +export { skillEntry } from './skillEntry'; +export type { SkillReadResult, SkillsOptions, SkillSource, SkillSourceOptions, SkillWarning } from './types'; +export type { + DirectoryResource, + GetSkillResult, + ListSkillsResult, + ReadResourceDirectoryResult, + Skill, + SkillCacheScope, + SkillFrontmatter, + SkillResourceEntry, + SkillResources, + SkillsCapability +} from '@modelcontextprotocol/core/ext/skills'; +export { + DIRECTORY_MIME_TYPE, + RESOURCES_DIRECTORY_READ_METHOD, + SKILL_LIMITS, + SKILL_MANIFEST_FILENAME, + SKILL_URI_SCHEME, SKILLS_EXTENSION_ID, SKILLS_GET_METHOD, - SKILLS_LIST_METHOD + SKILLS_LIST_METHOD, + SKILLS_META_PREFIX } from '@modelcontextprotocol/core/ext/skills'; -import { isModernProtocolVersion } from '@modelcontextprotocol/core-internal'; -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 for `skills/list` and `skills/get` on protocol revision - * 2026-07-28 onward. Defaults to `ttlMs: 0` and `cacheScope: 'private'`; - * omitted entirely from responses on older protocol revisions. - */ - 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 = { ttlMs: cacheHint?.ttlMs ?? 0, cacheScope: cacheHint?.cacheScope ?? 'private' }; - // Resolve at request time: installation precedes protocol negotiation. - // Use the instance's negotiated era, not caller-supplied request metadata. - const hintsForRequest = () => (isModernProtocolVersion(server.getNegotiatedProtocolVersion() ?? '') ? hints : {}); - - 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) } : {}), - ...hintsForRequest() - }; - } - ); - - 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, ...hintsForRequest() }; - }); - - return { skills: byUri }; -} diff --git a/packages/server/src/ext/skills/internal.ts b/packages/server/src/ext/skills/internal.ts new file mode 100644 index 0000000000..c4706e39ef --- /dev/null +++ b/packages/server/src/ext/skills/internal.ts @@ -0,0 +1,4 @@ +import type { SkillSource } from './types'; + +/** Sources whose entries the SDK built itself and so does not re-validate per request. Package-internal. */ +export const trustedSources = new WeakSet(); diff --git a/packages/server/src/ext/skills/skillEntry.ts b/packages/server/src/ext/skills/skillEntry.ts new file mode 100644 index 0000000000..c6d0fd9b0e --- /dev/null +++ b/packages/server/src/ext/skills/skillEntry.ts @@ -0,0 +1,28 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { buildSkillEntry, encodeUtf8, joinUri } from '@modelcontextprotocol/core-internal'; + +import { SkillCatalogError } from './errors'; +import { splitRelativePath } from './files'; + +/** + * Builds a valid entry — digests, sizes, frontmatter, rule checks — from one + * skill's files. Keys are paths under the skill's folder (`SKILL.md`, + * `references/GUIDE.md`). For `SkillSource` authors who publish digests. + */ +export async function skillEntry( + skillPath: string, + files: Record, + options: { baseUri?: string } = {} +): Promise { + const segments = splitRelativePath(skillPath, 'skillPath'); + const baseUri = options.baseUri ?? 'skill://'; + const inputs = Object.entries(files).map(([path, content]) => ({ + uri: joinUri(baseUri, [...segments, ...splitRelativePath(path, 'file path')]), + bytes: typeof content === 'string' ? encodeUtf8(content) : content + })); + const { skill, problems } = await buildSkillEntry(joinUri(baseUri, [...segments, 'SKILL.md']), inputs); + if (skill === undefined || problems.length > 0) { + throw new SkillCatalogError(problems.map(message => ({ path: skillPath, message }))); + } + return skill; +} diff --git a/packages/server/src/ext/skills/types.ts b/packages/server/src/ext/skills/types.ts new file mode 100644 index 0000000000..0ebf3d3cef --- /dev/null +++ b/packages/server/src/ext/skills/types.ts @@ -0,0 +1,44 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import type { CacheHint, Resource, ServerContext } from '@modelcontextprotocol/core-internal'; + +/** A problem found while building or serving skills. `path` names the file, folder or URI. */ +export interface SkillWarning { + path: string; + message: string; +} + +/** A skill file's content: text (sent as `text`) or bytes (sent as a base64 `blob`). */ +export type SkillReadResult = { text: string; mimeType?: string } | { bytes: Uint8Array; mimeType?: string }; + +/** A catalog you build yourself: per-user, generated per request, too large to enumerate, or "dynamic". */ +export interface SkillSource { + /** One page of entries; an empty or partial listing is allowed. */ + list(params: { cursor?: string }, ctx: ServerContext): Promise<{ skills: Skill[]; nextCursor?: string }>; + /** The entry whose `SKILL.md` is at `uri`, listed or not; `undefined` when none is served. */ + get(uri: string, ctx: ServerContext): Promise; + /** A skill file's content; `undefined` when no such file is served. */ + read(uri: string, ctx: ServerContext): Promise; + /** Direct children of a directory resource; `undefined` when `uri` is not one. Implementing it declares `directoryRead`. */ + readDirectory?( + uri: string, + params: { cursor?: string }, + ctx: ServerContext + ): Promise<{ resources: Resource[]; nextCursor?: string } | undefined>; + /** What appears in `resources/list` (a small set; not paged). Absent: nothing is listed. */ + listResources?(ctx: ServerContext): Promise; +} + +export interface SkillsOptions { + /** `ttlMs` / `cacheScope` for skills/list, skills/get, directory reads and skill files (2026-07-28). Default `0` / `private`. */ + cacheHint?: CacheHint; + /** Turn problems that are otherwise warnings into errors. Default `false`. */ + strict?: boolean; + /** Where warnings go. Default: `console.warn` (stderr). */ + onWarning?: (warning: SkillWarning) => void; +} + +export interface SkillSourceOptions extends SkillsOptions { + source: SkillSource; + /** Every URI the source serves starts with this. Default `skill://`. */ + baseUri?: string; +} diff --git a/packages/server/test/ext/skills/extension.test.ts b/packages/server/test/ext/skills/extension.test.ts new file mode 100644 index 0000000000..dede6f5e45 --- /dev/null +++ b/packages/server/test/ext/skills/extension.test.ts @@ -0,0 +1,228 @@ +import { createHash } from 'node:crypto'; + +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import type { SkillSource, SkillWarning } from '../../../src/ext/skills/index'; +import { SkillCatalogError, skillEntry, SkillsExtension } from '../../../src/ext/skills/index'; +import { errorOf, LEGACY, newServer, resultOf } from './harness'; + +const digest = `sha256:${'a'.repeat(64)}`; +const gitWorkflow: Skill = { + uri: 'skill://git-workflow/SKILL.md', + frontmatter: { name: 'git-workflow', description: 'Branching and review conventions.' }, + resources: [{ uri: 'skill://git-workflow/SKILL.md', digest, size: 5 }] +}; + +function sourceOf(overrides: Partial = {}): SkillSource { + return { + list: async () => ({ skills: [gitWorkflow] }), + get: async uri => (uri === gitWorkflow.uri ? gitWorkflow : undefined), + read: async uri => (uri === gitWorkflow.uri ? { text: '# Git', mimeType: 'text/markdown' } : undefined), + ...overrides + }; +} + +function attached(options: Partial[0]> = {}) { + const warnings: SkillWarning[] = []; + const mcp = newServer(); + new SkillsExtension({ source: sourceOf(), onWarning: warning => warnings.push(warning), ...options }).attach(mcp); + return { mcp, warnings }; +} + +describe('capability', () => { + it('declares the extension, with directoryRead only when the source reads directories', () => { + expect(attached().mcp.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': {} }); + const withDirectories = attached({ source: sourceOf({ readDirectory: async () => ({ resources: [] }) }) }); + expect(withDirectories.mcp.server.getCapabilities().extensions).toEqual({ + 'io.modelcontextprotocol/skills': { directoryRead: true } + }); + expect(withDirectories.mcp.server.getCapabilities().resources).toBeDefined(); + }); +}); + +describe('skills/list', () => { + it('serves the page with caching fields on 2026-07-28 only', async () => { + const { mcp } = attached({ cacheHint: { ttlMs: 60_000, cacheScope: 'public' } }); + expect(await resultOf(mcp, 'skills/list')).toMatchObject({ skills: [gitWorkflow], ttlMs: 60_000, cacheScope: 'public' }); + const legacy = await resultOf(mcp, 'skills/list', {}, LEGACY); + expect(legacy['skills']).toEqual([gitWorkflow]); + expect(legacy).not.toHaveProperty('ttlMs'); + expect(legacy).not.toHaveProperty('cacheScope'); + }); + + it('defaults the caching fields to 0 / private', async () => { + expect(await resultOf(attached().mcp, 'skills/list')).toMatchObject({ ttlMs: 0, cacheScope: 'private' }); + }); + + it('passes nextCursor through', async () => { + const { mcp } = attached({ source: sourceOf({ list: async () => ({ skills: [gitWorkflow], nextCursor: 'n' }) }) }); + expect(await resultOf(mcp, 'skills/list')).toMatchObject({ nextCursor: 'n' }); + }); + + it('drops invalid entries from a source and warns, or fails with -32603 when strict', async () => { + const broken = { ...gitWorkflow, uri: 'skill://broken/SKILL.md' }; + const lenient = attached({ source: sourceOf({ list: async () => ({ skills: [gitWorkflow, broken] }) }) }); + const { skills } = await resultOf(lenient.mcp, 'skills/list'); + expect(skills).toEqual([gitWorkflow]); + expect(lenient.warnings[0]).toMatchObject({ path: 'skill://broken/SKILL.md' }); + const strict = attached({ strict: true, source: sourceOf({ list: async () => ({ skills: [broken] }) }) }); + expect(await errorOf(strict.mcp, 'skills/list')).toMatchObject({ code: -32_603 }); + }); +}); + +describe('skills/get', () => { + it('returns the entry with caching fields', async () => { + expect(await resultOf(attached().mcp, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ skill: gitWorkflow, ttlMs: 0 }); + }); + + it('answers -32602 for a skill it does not serve', async () => { + expect(await errorOf(attached().mcp, 'skills/get', { uri: 'skill://nope/SKILL.md' })).toMatchObject({ code: -32_602 }); + }); + + it('answers -32603 when the source returns a different or invalid skill', async () => { + const other = { + ...gitWorkflow, + uri: 'skill://other/SKILL.md', + frontmatter: { name: 'other', description: 'd' }, + resources: [{ uri: 'skill://other/SKILL.md', digest, size: 1 }] + }; + const differs = attached({ source: sourceOf({ get: async () => other }) }); + expect(await errorOf(differs.mcp, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ code: -32_603 }); + const invalid = { ...gitWorkflow, frontmatter: { name: 'Wrong', description: 'd' } }; + const broken = attached({ source: sourceOf({ get: async () => invalid }) }); + expect(await errorOf(broken.mcp, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ code: -32_603 }); + }); +}); + +describe('resources/read through the skill template', () => { + it('serves text, serves bytes as base64, and answers -32602 for unknown files', async () => { + const bytes = Uint8Array.from([0, 1, 2, 255]); + const { mcp } = attached({ + source: sourceOf({ + read: async uri => { + if (uri === gitWorkflow.uri) return { text: '# Git', mimeType: 'text/markdown' }; + if (uri === 'skill://git-workflow/logo.png') return { bytes, mimeType: 'image/png' }; + return; + } + }) + }); + const { contents: text } = await resultOf(mcp, 'resources/read', { uri: gitWorkflow.uri }); + expect(text).toEqual([{ uri: gitWorkflow.uri, mimeType: 'text/markdown', text: '# Git' }]); + const { contents: binary } = await resultOf(mcp, 'resources/read', { uri: 'skill://git-workflow/logo.png' }); + expect(binary).toEqual([ + { uri: 'skill://git-workflow/logo.png', mimeType: 'image/png', blob: Buffer.from(bytes).toString('base64') } + ]); + expect(await errorOf(mcp, 'resources/read', { uri: 'skill://git-workflow/missing.md' })).toMatchObject({ code: -32_602 }); + }); + + it('registers the skill:// template under the extension id', async () => { + const { resourceTemplates } = await resultOf(attached().mcp, 'resources/templates/list'); + expect(resourceTemplates).toEqual([ + expect.objectContaining({ name: 'io.modelcontextprotocol/skills', uriTemplate: 'skill://{+path}' }) + ]); + }); +}); + +describe('resources/directory/read', () => { + it('passes pages through with caching fields, answers -32602 for non-directories, and is absent without readDirectory', async () => { + const child = { uri: 'skill://git-workflow/SKILL.md', name: 'SKILL.md', mimeType: 'text/markdown' }; + const { mcp } = attached({ + source: sourceOf({ + readDirectory: async uri => (uri === 'skill://git-workflow' ? { resources: [child], nextCursor: 'n' } : undefined) + }) + }); + expect(await resultOf(mcp, 'resources/directory/read', { uri: 'skill://git-workflow' })).toMatchObject({ + resources: [child], + nextCursor: 'n', + ttlMs: 0, + cacheScope: 'private' + }); + expect(await errorOf(mcp, 'resources/directory/read', { uri: 'skill://git-workflow/SKILL.md' })).toMatchObject({ code: -32_602 }); + expect(await errorOf(attached().mcp, 'resources/directory/read', { uri: 'skill://git-workflow' })).toMatchObject({ code: -32_601 }); + }); +}); + +describe('resources/list', () => { + it('lists nothing from a source by default and what listResources returns otherwise', async () => { + const { resources: none } = await resultOf(attached().mcp, 'resources/list'); + expect(none).toEqual([]); + const listed = { uri: gitWorkflow.uri, name: 'git-workflow', description: 'd', mimeType: 'text/markdown' }; + const outside = { uri: 'other://x/SKILL.md', name: 'x' }; + const { mcp, warnings } = attached({ source: sourceOf({ listResources: async () => [listed, outside] }) }); + const { resources } = await resultOf(mcp, 'resources/list'); + expect(resources).toEqual([listed]); + expect(warnings[0]).toMatchObject({ path: 'other://x/SKILL.md' }); + }); +}); + +describe('baseUri', () => { + it('serves a source under another scheme', async () => { + const github: Skill = { + uri: 'github://owner/repo/skills/refunds/SKILL.md', + frontmatter: { name: 'refunds', description: 'Refunds.' }, + resources: [{ uri: 'github://owner/repo/skills/refunds/SKILL.md', digest, size: 1 }] + }; + const { mcp } = attached({ + baseUri: 'github://owner/repo/skills', + source: sourceOf({ + list: async () => ({ skills: [github] }), + read: async uri => (uri === github.uri ? { text: 'x' } : undefined) + }) + }); + const { resourceTemplates } = await resultOf(mcp, 'resources/templates/list'); + expect(resourceTemplates).toEqual([expect.objectContaining({ uriTemplate: 'github://owner/repo/skills/{+path}' })]); + const { contents } = await resultOf(mcp, 'resources/read', { uri: github.uri }); + expect(contents).toEqual([{ uri: github.uri, text: 'x' }]); + }); + + it('rejects a baseUri without a scheme', () => { + expect(() => new SkillsExtension({ source: sourceOf(), baseUri: 'skills' })).toThrow(TypeError); + }); +}); + +describe('attach', () => { + it('is a no-op the second time on the same server and serves many servers', async () => { + const extension = new SkillsExtension({ source: sourceOf() }); + const first = newServer(); + const second = newServer(); + extension.attach(first); + extension.attach(first); + extension.attach(second); + expect(await resultOf(first, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ skill: gitWorkflow }); + expect(await resultOf(second, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ skill: gitWorkflow }); + }); + + it('throws after the server connected', async () => { + const mcp = newServer(); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await mcp.connect(serverSide); + expect(() => new SkillsExtension({ source: sourceOf() }).attach(mcp)).toThrow(/before connecting/); + await mcp.close(); + }); + + it('rejects an invalid cacheHint at construction', () => { + expect(() => new SkillsExtension({ source: sourceOf(), cacheHint: { ttlMs: -1 } })).toThrow(); + }); +}); + +describe('skillEntry', () => { + it('builds a valid entry from files with real digests', async () => { + const skillMd = '---\nname: refunds\ndescription: Refund workflow.\n---\n# Refunds\n'; + const skill = await skillEntry('acme/refunds', { 'SKILL.md': skillMd, 'references/policy.md': '# Policy\n' }); + expect(skill.uri).toBe('skill://acme/refunds/SKILL.md'); + expect(skill.resources).toContainEqual({ + uri: 'skill://acme/refunds/SKILL.md', + digest: `sha256:${createHash('sha256').update(skillMd).digest('hex')}`, + size: Buffer.byteLength(skillMd) + }); + }); + + it('throws SkillCatalogError for an invalid skill and TypeError for an escaping path', async () => { + await expect(skillEntry('acme/refunds', { 'SKILL.md': '---\nname: other\ndescription: d\n---\n' })).rejects.toBeInstanceOf( + SkillCatalogError + ); + await expect(skillEntry('acme/../x', { 'SKILL.md': '' })).rejects.toBeInstanceOf(TypeError); + }); +}); diff --git a/packages/server/test/ext/skills/harness.ts b/packages/server/test/ext/skills/harness.ts new file mode 100644 index 0000000000..12009433ce --- /dev/null +++ b/packages/server/test/ext/skills/harness.ts @@ -0,0 +1,75 @@ +import type { JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core-internal'; + +import { invoke } from '../../../src/server/invoke'; +import { McpServer } from '../../../src/server/mcp'; + +export const MODERN = '2026-07-28'; +export const LEGACY = '2025-11-25'; + +export interface Reply { + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +let nextId = 1; + +export function newServer(): McpServer { + return new McpServer({ name: 'skills-test', version: '1.0.0' }); +} + +/** Sends one JSON-RPC request through the real dispatch path, on the given protocol revision. */ +export async function call( + mcp: McpServer, + method: string, + params: Record = {}, + revision: string = MODERN +): Promise { + const modern = revision === MODERN; + const classification: MessageClassification = modern ? { era: 'modern', revision } : { era: 'legacy', revision }; + const meta = modern + ? { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'skills-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + : undefined; + const message = { + jsonrpc: '2.0', + id: nextId++, + method, + params: meta === undefined ? params : { ...params, _meta: meta } + } as JSONRPCRequest; + setNegotiatedProtocolVersion(mcp.server, revision); + const response = await invoke(mcp, message, { classification }); + return (await response.json()) as Reply; +} + +/** The result of a request that must succeed. */ +export async function resultOf( + mcp: McpServer, + method: string, + params: Record = {}, + revision: string = MODERN +): Promise> { + const reply = await call(mcp, method, params, revision); + if (reply.result === undefined) throw new Error(`${method} failed: ${JSON.stringify(reply.error)}`); + return reply.result; +} + +/** The error of a request that must fail. */ +export async function errorOf( + mcp: McpServer, + method: string, + params: Record = {}, + revision: string = MODERN +): Promise> { + const reply = await call(mcp, method, params, revision); + if (reply.error === undefined) throw new Error(`${method} unexpectedly succeeded`); + return reply.error; +} diff --git a/packages/server/test/ext/skills/installSkills.test.ts b/packages/server/test/ext/skills/installSkills.test.ts deleted file mode 100644 index f3c70cc048..0000000000 --- a/packages/server/test/ext/skills/installSkills.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * 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, - SUPPORTED_PROTOCOL_VERSIONS -} 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, type InstallSkillsOptions, type SkillsCacheHint } 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: Omit = {}): 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, - classification: MessageClassification = MODERN -): Promise> { - setNegotiatedProtocolVersion(server, classification.revision); - const response = await invoke(server, message, { classification }); - 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); - }); -}); - -describe.each(['skills/list', 'skills/get'])('%s cache hints on the wire', method => { - const params = method === 'skills/get' ? { uri: 'skill://alpha/SKILL.md' } : {}; - const payload = method === 'skills/get' ? { skill: skill('alpha') } : { skills: [skill('alpha')] }; - - it.each<{ name: string; cacheHint?: SkillsCacheHint; ttlMs: number; cacheScope: string }>([ - { name: 'omitted', ttlMs: 0, cacheScope: 'private' }, - { name: 'empty', cacheHint: {}, ttlMs: 0, cacheScope: 'private' }, - { name: 'TTL only', cacheHint: { ttlMs: 60_000 }, ttlMs: 60_000, cacheScope: 'private' }, - { name: 'scope only', cacheHint: { cacheScope: 'public' }, ttlMs: 0, cacheScope: 'public' }, - { name: 'both', cacheHint: { ttlMs: 60_000, cacheScope: 'public' }, ttlMs: 60_000, cacheScope: 'public' } - ])('fills modern fields when the hint is $name', async ({ cacheHint, ttlMs, cacheScope }) => { - const server = serverWith([skill('alpha')], { cacheHint }); - const result = resultOf(await call(server, request(method, params))); - expect(result).toMatchObject({ ...payload, resultType: 'complete', ttlMs, cacheScope }); - }); - - it.each(SUPPORTED_PROTOCOL_VERSIONS)('omits modern fields on %s even with configured hints', async revision => { - for (const cacheHint of [undefined, { ttlMs: 60_000, cacheScope: 'public' }] as const) { - const server = serverWith([skill('alpha')], { cacheHint }); - const result = resultOf(await call(server, { jsonrpc: '2.0', id: 1, method, params }, { era: 'legacy', revision })); - expect(result).toEqual(payload); - } - }); - - it('does not let modern-looking metadata override the negotiated legacy era', async () => { - const server = serverWith([skill('alpha')], { cacheHint: { ttlMs: 60_000, cacheScope: 'public' } }); - const result = resultOf(await call(server, request(method, params), { era: 'legacy', revision: '2025-11-25' })); - expect(result).toEqual(payload); - }); -}); - -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); - }); -}); From 498c89ff86c81d245bb38d354e7e091002716d4f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 05:03:08 +0300 Subject: [PATCH 10/45] feat(server): build skill catalogs from a storage adapter A SkillFiles adapter (list and read over relative paths) with an in-memory implementation, and buildCatalogSnapshot: it walks the adapter, builds and checks every skill (invalid ones skipped with a warning, or collected under strict), and assembles the entries, file metadata, directories and listing a catalog serves. Metadata only; file contents are never held. --- .../server/src/ext/skills/catalogBuild.ts | 215 ++++++++++++++++++ packages/server/src/ext/skills/files.ts | 31 +++ packages/server/src/ext/skills/types.ts | 28 +++ .../test/ext/skills/catalogBuild.test.ts | 156 +++++++++++++ packages/server/test/ext/skills/fixtures.ts | 32 +++ 5 files changed, 462 insertions(+) create mode 100644 packages/server/src/ext/skills/catalogBuild.ts create mode 100644 packages/server/test/ext/skills/catalogBuild.test.ts create mode 100644 packages/server/test/ext/skills/fixtures.ts diff --git a/packages/server/src/ext/skills/catalogBuild.ts b/packages/server/src/ext/skills/catalogBuild.ts new file mode 100644 index 0000000000..d45ae22731 --- /dev/null +++ b/packages/server/src/ext/skills/catalogBuild.ts @@ -0,0 +1,215 @@ +import type { Skill, SkillResourceEntry } from '@modelcontextprotocol/core/ext/skills'; +import { DIRECTORY_MIME_TYPE } from '@modelcontextprotocol/core/ext/skills'; +import type { Resource } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, compareUris, isRoundTripUri, joinUri, mimeTypeFor } from '@modelcontextprotocol/core-internal'; + +import { SkillCatalogError } from './errors'; +import type { SkillFileEntry, SkillFiles, SkillWarning } from './types'; + +/** One published file: where it is in storage and what the entry says about it. */ +export interface CatalogFile { + readonly path: string; + readonly uri: string; + readonly size: number; + readonly digest: string; + readonly mimeType: string; +} + +/** Everything a catalog serves, built once per load or reload. Metadata only, never file contents. */ +export interface CatalogSnapshot { + readonly ordered: readonly Skill[]; + readonly skills: ReadonlyMap; + readonly files: ReadonlyMap; + readonly dirs: ReadonlyMap; + readonly listed: readonly Resource[]; +} + +export interface CatalogBuildOptions { + readonly prefix: readonly string[]; + readonly limits: { readonly files: number; readonly bytes: number }; + readonly strict: boolean; + readonly warn: (warning: SkillWarning) => void; +} + +interface TreeEntry { + readonly path: string; + readonly kind: SkillFileEntry['kind']; +} + +interface BuiltSkill { + readonly dir: string; + readonly skill: Skill; + readonly files: readonly CatalogFile[]; + readonly manifestSize: number; +} + +type UriOf = (path: string) => string; + +const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1); + +const dirname = (path: string): string => { + const slash = path.lastIndexOf('/'); + return slash === -1 ? '' : path.slice(0, slash); +}; + +const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** Every non-hidden entry below the root, depth first. Storage errors fail the build. */ +async function walk(files: SkillFiles): Promise { + const tree: TreeEntry[] = []; + const visit = async (dir: string): Promise => { + let entries: SkillFileEntry[]; + try { + entries = await files.list(dir); + } catch (error) { + throw new SkillCatalogError([{ path: dir === '' ? '.' : dir, message: `cannot be listed: ${errorText(error)}` }]); + } + for (const entry of entries.toSorted((a, b) => compareUris(a.name, b.name))) { + if (entry.name.startsWith('.')) continue; + const path = dir === '' ? entry.name : `${dir}/${entry.name}`; + tree.push({ path, kind: entry.kind }); + if (entry.kind === 'directory') await visit(path); + } + }; + await visit(''); + return tree; +} + +async function readFile(files: SkillFiles, path: string): Promise { + try { + return await files.read(path); + } catch (error) { + throw new SkillCatalogError([{ path, message: `cannot be read: ${errorText(error)}` }]); + } +} + +/** Walks the storage, builds and checks every skill, and assembles what the catalog serves. */ +export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBuildOptions): Promise { + const tree = await walk(files); + const problems: SkillWarning[] = []; + const report = (warning: SkillWarning): void => { + if (options.strict) problems.push(warning); + else options.warn(warning); + }; + const uriOf: UriOf = path => joinUri('skill://', [...options.prefix, ...path.split('/')]); + + for (const entry of tree) { + const name = basename(entry.path); + if (entry.kind === 'file' && name !== 'SKILL.md' && name.toLowerCase() === 'skill.md') { + options.warn({ path: entry.path, message: 'did you mean SKILL.md? File names are case-sensitive' }); + } + } + + const built: BuiltSkill[] = []; + for (const entry of tree) { + if (entry.kind !== 'file' || basename(entry.path) !== 'SKILL.md') continue; + const dir = dirname(entry.path); + if (dir === '') { + report({ + path: entry.path, + message: 'a SKILL.md at the root has no skill path; point loadSkills (or your adapter) at its parent folder (skipped)' + }); + continue; + } + const skill = await buildOne(files, tree, dir, uriOf, options, report); + if (skill !== undefined) built.push(skill); + } + if (problems.length > 0) throw new SkillCatalogError(problems); + if (built.length === 0) options.warn({ path: '.', message: 'no skills found (a skill is a folder containing SKILL.md)' }); + return assemble(built, tree, uriOf); +} + +async function buildOne( + files: SkillFiles, + tree: readonly TreeEntry[], + dir: string, + uriOf: UriOf, + options: CatalogBuildOptions, + report: (warning: SkillWarning) => void +): Promise { + const inside = tree.filter(entry => entry.path.startsWith(`${dir}/`)); + const unsupported = inside.find(entry => entry.kind === 'unsupported'); + if (unsupported !== undefined) { + report({ + path: unsupported.path, + message: 'symlinks and other special files are not followed; copy the file instead (skill skipped)' + }); + return undefined; + } + const inputs: Array<{ path: string; uri: string; bytes: Uint8Array }> = []; + for (const entry of inside) { + if (entry.kind !== 'file') continue; + const uri = uriOf(entry.path); + if (!isRoundTripUri(uri)) { + report({ path: entry.path, message: 'this name cannot be represented in a URI (skill skipped)' }); + return undefined; + } + inputs.push({ path: entry.path, uri, bytes: await readFile(files, entry.path) }); + } + const manifestUri = uriOf(`${dir}/SKILL.md`); + const { skill, problems } = await buildSkillEntry(manifestUri, inputs); + if (skill === undefined || problems.length > 0) { + for (const message of problems) report({ path: `${dir}/SKILL.md`, message: `${message} (skill skipped)` }); + return undefined; + } + let totalBytes = 0; + for (const input of inputs) totalBytes += input.bytes.byteLength; + if (inputs.length > options.limits.files || totalBytes > options.limits.bytes) { + report({ + path: dir, + message: + `${inputs.length} files / ${totalBytes} bytes exceeds the limit of ${options.limits.files} files / ` + + `${options.limits.bytes} bytes; some hosts may decline it. Set strict: true to fail instead, or raise limits` + }); + } + const digests = new Map((skill.resources as SkillResourceEntry[]).map(resource => [resource.uri, resource.digest])); + const catalogFiles = inputs.map(input => ({ + path: input.path, + uri: input.uri, + size: input.bytes.byteLength, + digest: digests.get(input.uri)!, + mimeType: mimeTypeFor(basename(input.path)) + })); + const manifestSize = inputs.find(input => input.uri === manifestUri)?.bytes.byteLength ?? 0; + return { dir, skill, files: catalogFiles, manifestSize }; +} + +function assemble(built: readonly BuiltSkill[], tree: readonly TreeEntry[], uriOf: UriOf): CatalogSnapshot { + const ordered = built.map(item => item.skill).toSorted((a, b) => compareUris(a.uri, b.uri)); + const files = new Map(); + const dirPaths = new Set(); + for (const item of built) { + for (const file of item.files) files.set(file.uri, file); + dirPaths.add(item.dir); + for (const entry of tree) { + if (entry.kind === 'directory' && entry.path.startsWith(`${item.dir}/`)) dirPaths.add(entry.path); + } + } + const dirs = new Map(); + for (const dirPath of dirPaths) { + const children: Resource[] = []; + for (const entry of tree) { + if (dirname(entry.path) !== dirPath) continue; + if (entry.kind === 'directory') { + children.push({ uri: uriOf(entry.path), name: basename(entry.path), mimeType: DIRECTORY_MIME_TYPE }); + continue; + } + const file = files.get(uriOf(entry.path)); + if (entry.kind === 'file' && file !== undefined) { + children.push({ uri: file.uri, name: basename(entry.path), mimeType: file.mimeType, size: file.size }); + } + } + children.sort((a, b) => compareUris(a.uri, b.uri)); + dirs.set(uriOf(dirPath), children); + } + const listed = built + .map(item => ({ + uri: item.skill.uri, + name: item.skill.frontmatter.name, + description: item.skill.frontmatter.description, + mimeType: 'text/markdown', + size: item.manifestSize + })) + .toSorted((a, b) => compareUris(a.uri, b.uri)); + return { ordered, skills: new Map(ordered.map(skill => [skill.uri, skill])), files, dirs, listed }; +} diff --git a/packages/server/src/ext/skills/files.ts b/packages/server/src/ext/skills/files.ts index daed985bb3..d497db9fc9 100644 --- a/packages/server/src/ext/skills/files.ts +++ b/packages/server/src/ext/skills/files.ts @@ -1,3 +1,7 @@ +import { encodeUtf8 } from '@modelcontextprotocol/core-internal'; + +import type { SkillFileEntry, SkillFiles } from './types'; + /** Splits a relative `/`-separated path, rejecting `..` and empty paths. */ export function splitRelativePath(path: string, label: string): string[] { const segments = path.split('/').filter(segment => segment !== '' && segment !== '.'); @@ -6,3 +10,30 @@ export function splitRelativePath(path: string, label: string): string[] { } return segments; } + +/** An in-memory adapter. The record is read on every call, so `reload()` sees later changes to it. */ +export function memorySkillFiles(files: Record): SkillFiles { + const current = (): Map => { + const entries = new Map(); + for (const [path, content] of Object.entries(files)) entries.set(splitRelativePath(path, 'file path').join('/'), content); + return entries; + }; + return { + async list(dir) { + const prefix = dir === '' ? '' : `${dir}/`; + const children = new Map(); + for (const path of current().keys()) { + if (!path.startsWith(prefix)) continue; + const [name, ...deeper] = path.slice(prefix.length).split('/'); + if (name !== undefined) children.set(name, { name, kind: deeper.length > 0 ? 'directory' : 'file' }); + } + if (dir !== '' && children.size === 0) throw new Error(`no such directory: ${dir}`); + return [...children.values()]; + }, + async read(path) { + const content = current().get(path); + if (content === undefined) throw new Error(`no such file: ${path}`); + return typeof content === 'string' ? encodeUtf8(content) : content; + } + }; +} diff --git a/packages/server/src/ext/skills/types.ts b/packages/server/src/ext/skills/types.ts index 0ebf3d3cef..57b3d969a5 100644 --- a/packages/server/src/ext/skills/types.ts +++ b/packages/server/src/ext/skills/types.ts @@ -42,3 +42,31 @@ export interface SkillSourceOptions extends SkillsOptions { /** Every URI the source serves starts with this. Default `skill://`. */ baseUri?: string; } + +/** One child of a directory. `unsupported` marks symlinks and other special files; the catalog skips the skill that contains one. */ +export interface SkillFileEntry { + name: string; + kind: 'file' | 'directory' | 'unsupported'; +} + +/** Storage adapter for catalogs: two operations over relative, `/`-separated paths (`''` is the root). */ +export interface SkillFiles { + list(dir: string): Promise; + read(path: string): Promise; +} + +export interface SkillCatalogOptions extends SkillsOptions { + /** Organisational prefix: `'acme'` serves `skill://acme//…`. */ + prefix?: string; + /** Entries per `skills/list` and directory page: a positive integer or `Infinity`. Default 100. */ + pageSize?: number; + /** Publication limits per skill. Default `SKILL_LIMITS` (512 files, 16 MiB). Beyond them: a warning, or an error under `strict`. */ + limits?: { files?: number; bytes?: number }; +} + +/** What a `reload()` changed, as `SKILL.md` URIs. */ +export interface SkillCatalogChanges { + added: string[]; + removed: string[]; + changed: string[]; +} diff --git a/packages/server/test/ext/skills/catalogBuild.test.ts b/packages/server/test/ext/skills/catalogBuild.test.ts new file mode 100644 index 0000000000..6a366d9d4b --- /dev/null +++ b/packages/server/test/ext/skills/catalogBuild.test.ts @@ -0,0 +1,156 @@ +import { createHash } from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; + +import type { CatalogBuildOptions } from '../../../src/ext/skills/catalogBuild'; +import { buildCatalogSnapshot } from '../../../src/ext/skills/catalogBuild'; +import { SkillCatalogError } from '../../../src/ext/skills/errors'; +import { memorySkillFiles } from '../../../src/ext/skills/files'; +import type { SkillFiles, SkillWarning } from '../../../src/ext/skills/types'; +import { ESCALATION, fixtureFiles, GIT, LOGO, PNG, PURCHASE_ORDER, REFUNDS, REMBOURSE } from './fixtures'; + +function optionsWith(overrides: Partial = {}) { + const warnings: SkillWarning[] = []; + const options: CatalogBuildOptions = { + prefix: [], + limits: { files: 512, bytes: 16_777_216 }, + strict: false, + warn: warning => warnings.push(warning), + ...overrides + }; + return { warnings, options }; +} + +const INVALID = '---\nname: other\ndescription: d\n---\n'; + +describe('buildCatalogSnapshot', () => { + it('builds flat, URI-sorted entries; a nested skill is also a file of the enclosing skill', async () => { + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith().options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(snapshot.skills.get(REFUNDS)?.resources).toEqual( + expect.arrayContaining([ + { uri: ESCALATION, digest: expect.stringMatching(/^sha256:[\da-f]{64}$/), size: expect.any(Number) }, + { uri: LOGO, digest: `sha256:${createHash('sha256').update(PNG).digest('hex')}`, size: PNG.byteLength } + ]) + ); + }); + + it('skips hidden files and folders', async () => { + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith().options); + const paths = [...snapshot.files.values()].map(file => file.path); + expect(paths).toContain('git-workflow/references/REVIEW.md'); + expect(paths.some(path => path.split('/').some(segment => segment.startsWith('.')))).toBe(false); + }); + + it('lists one SKILL.md resource per skill', async () => { + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith().options); + expect(snapshot.listed).toEqual([ + { uri: REFUNDS, name: 'refunds', description: 'Refund workflow.', mimeType: 'text/markdown', size: expect.any(Number) }, + { uri: ESCALATION, name: 'escalation', description: 'Escalate a refund.', mimeType: 'text/markdown', size: expect.any(Number) }, + { + uri: GIT, + name: 'git-workflow', + description: 'Branching and review conventions.', + mimeType: 'text/markdown', + size: expect.any(Number) + } + ]); + }); + + it('treats skill roots and folders inside skills as directories, but not organisational folders', async () => { + const { dirs } = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith().options); + expect(dirs.has('skill://acme')).toBe(false); + expect(dirs.has('skill://acme/billing')).toBe(false); + expect(dirs.get('skill://acme/billing/refunds')?.map(child => [child.name, child.mimeType])).toEqual([ + ['SKILL.md', 'text/markdown'], + ['assets', 'inode/directory'], + ['escalation', 'inode/directory'], + ['templates', 'inode/directory'] + ]); + expect(dirs.get('skill://acme/billing/refunds/templates')?.map(child => [child.name, child.uri])).toEqual([ + ['Purchase Order.md', PURCHASE_ORDER], + ['Remboursé.md', REMBOURSE] + ]); + }); + + it('puts a prefix in front of every URI', async () => { + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith({ prefix: ['shop'] }).options); + expect(snapshot.ordered.every(skill => skill.uri.startsWith('skill://shop/'))).toBe(true); + }); + + it('skips invalid skills and warns about each one', async () => { + const files = { + ...fixtureFiles(), + 'bad/SKILL.md': INVALID, + 'broken/SKILL.md': '---\nname: &a broken\ndescription: d\n---\n', + 'SKILL.md': '---\nname: root\ndescription: d\n---\n', + 'notes/skill.md': '# not a skill' + }; + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(memorySkillFiles(files), options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(warnings.map(warning => warning.path).toSorted()).toEqual(['SKILL.md', 'bad/SKILL.md', 'broken/SKILL.md', 'notes/skill.md']); + }); + + it('skips a skill that contains a symlink or other special file', async () => { + const memory = memorySkillFiles(fixtureFiles()); + const files: SkillFiles = { + list: async dir => { + const entries = await memory.list(dir); + return dir === 'git-workflow' ? [...entries, { name: 'run.sh', kind: 'unsupported' }] : entries; + }, + read: path => memory.read(path) + }; + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(files, options); + expect(snapshot.skills.has(GIT)).toBe(false); + expect(warnings).toContainEqual(expect.objectContaining({ path: 'git-workflow/run.sh' })); + }); + + it('collects every problem into one SkillCatalogError when strict', async () => { + const files = { ...fixtureFiles(), 'bad/SKILL.md': INVALID, 'SKILL.md': '---\nname: r\ndescription: d\n---\n' }; + const error = await buildCatalogSnapshot(memorySkillFiles(files), optionsWith({ strict: true }).options).catch( + (error: unknown) => error + ); + expect(error).toBeInstanceOf(SkillCatalogError); + expect((error as SkillCatalogError).problems.map(problem => problem.path).toSorted()).toEqual(['SKILL.md', 'bad/SKILL.md']); + }); + + it('publishes an oversized skill with a warning, or fails when strict', async () => { + const limits = { files: 2, bytes: 16_777_216 }; + const lenient = optionsWith({ limits }); + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), lenient.options); + expect(snapshot.skills.has(REFUNDS)).toBe(true); + expect(lenient.warnings).toContainEqual({ path: 'acme/billing/refunds', message: expect.stringMatching(/exceeds the limit/) }); + await expect( + buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith({ limits, strict: true }).options) + ).rejects.toBeInstanceOf(SkillCatalogError); + }); + + it('always fails on storage errors', async () => { + const { options } = optionsWith(); + const unlistable: SkillFiles = { list: () => Promise.reject(new Error('EACCES')), read: () => Promise.reject(new Error('unused')) }; + await expect(buildCatalogSnapshot(unlistable, options)).rejects.toBeInstanceOf(SkillCatalogError); + const memory = memorySkillFiles(fixtureFiles()); + const unreadable: SkillFiles = { list: dir => memory.list(dir), read: () => Promise.reject(new Error('EIO')) }; + await expect(buildCatalogSnapshot(unreadable, options)).rejects.toBeInstanceOf(SkillCatalogError); + }); + + it('warns when no skills are found', async () => { + const { options, warnings } = optionsWith(); + await buildCatalogSnapshot(memorySkillFiles({}), options); + expect(warnings).toEqual([{ path: '.', message: expect.stringMatching(/no skills found/) }]); + }); +}); + +describe('memorySkillFiles', () => { + it('reads the record on every call and rejects unknown paths', async () => { + const record: Record = { 'a/SKILL.md': 'x' }; + const files = memorySkillFiles(record); + record['a/extra.md'] = 'y'; + const entries = await files.list('a'); + expect(entries.map(entry => entry.name).toSorted()).toEqual(['SKILL.md', 'extra.md']); + await expect(files.list('missing')).rejects.toThrow(); + await expect(files.read('a/missing.md')).rejects.toThrow(); + }); +}); diff --git a/packages/server/test/ext/skills/fixtures.ts b/packages/server/test/ext/skills/fixtures.ts new file mode 100644 index 0000000000..b924cb023e --- /dev/null +++ b/packages/server/test/ext/skills/fixtures.ts @@ -0,0 +1,32 @@ +export const GIT_SKILL_MD = + '---\nname: git-workflow\ndescription: Branching and review conventions.\n---\n# Git workflow\n\nSee references/REVIEW.md.\n'; +/** Windows-authored: UTF-8 BOM and CRLF line endings. */ +export const REFUNDS_SKILL_MD = '---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\n# Refunds\r\n'; +export const ESCALATION_SKILL_MD = '---\nname: escalation\ndescription: Escalate a refund.\n---\n# Escalation\n'; +export const PNG = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]); + +export const GIT = 'skill://git-workflow/SKILL.md'; +export const REFUNDS = 'skill://acme/billing/refunds/SKILL.md'; +export const ESCALATION = 'skill://acme/billing/refunds/escalation/SKILL.md'; +export const LOGO = 'skill://acme/billing/refunds/assets/logo.png'; +export const PURCHASE_ORDER = 'skill://acme/billing/refunds/templates/Purchase%20Order.md'; +export const REMBOURSE = 'skill://acme/billing/refunds/templates/Rembours%C3%A9.md'; + +/** + * A fresh, mutable file map: two top-level skills, a nested skill under an + * organisational prefix, file names with a space and a non-ASCII letter, a + * binary file and hidden files. + */ +export function fixtureFiles(): Record { + return { + 'git-workflow/SKILL.md': GIT_SKILL_MD, + 'git-workflow/references/REVIEW.md': '# Review\n', + 'git-workflow/.private-notes.md': 'hidden', + 'acme/billing/refunds/SKILL.md': REFUNDS_SKILL_MD, + 'acme/billing/refunds/templates/Purchase Order.md': '# PO\n', + 'acme/billing/refunds/templates/Remboursé.md': '# Remboursement\n', + 'acme/billing/refunds/assets/logo.png': PNG, + 'acme/billing/refunds/escalation/SKILL.md': ESCALATION_SKILL_MD, + '.DS_Store': 'junk' + }; +} From 173caffe9b8959e1639e939e64e11960c2db60df Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 05:28:50 +0300 Subject: [PATCH 11/45] fix(server): report symlinked skills and unencodable names, speed up memorySkillFiles A symlinked SKILL.md marks its folder as a skill, so it is skipped and reported like any other special file; special files outside every skill are reported too. A name that cannot be encoded as a URI skips its skill with a warning instead of aborting the build. memorySkillFiles reads paths directly and re-indexes only when the record's keys change. --- .../server/src/ext/skills/catalogBuild.ts | 49 ++++++-- packages/server/src/ext/skills/files.ts | 64 +++++++--- .../test/ext/skills/catalogBuild.test.ts | 110 +++++++++++++++++- 3 files changed, 198 insertions(+), 25 deletions(-) diff --git a/packages/server/src/ext/skills/catalogBuild.ts b/packages/server/src/ext/skills/catalogBuild.ts index d45ae22731..a850db88a9 100644 --- a/packages/server/src/ext/skills/catalogBuild.ts +++ b/packages/server/src/ext/skills/catalogBuild.ts @@ -54,6 +54,31 @@ const dirname = (path: string): string => { const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const NOT_FOLLOWED = 'symlinks and other special files are not followed; copy the file instead'; + +/** Any non-directory named exactly `SKILL.md` marks a skill, so a symlinked one is reported rather than ignored. */ +const isManifest = (entry: TreeEntry): boolean => entry.kind !== 'directory' && basename(entry.path) === 'SKILL.md'; + +/** True when some folder above `path` is one of `dirs`. */ +function isBelowAny(dirs: ReadonlySet, path: string): boolean { + for (let dir = dirname(path); dir !== ''; dir = dirname(dir)) { + if (dirs.has(dir)) return true; + } + return false; +} + +/** The URI of `path`, or `undefined` when a name in it has no URI form (a lone surrogate, or no round trip). */ +function representableUri(uriOf: UriOf, path: string): string | undefined { + let uri: string; + try { + uri = uriOf(path); + } catch (error) { + if (error instanceof URIError) return undefined; + throw error; + } + return isRoundTripUri(uri) ? uri : undefined; +} + /** Every non-hidden entry below the root, depth first. Storage errors fail the build. */ async function walk(files: SkillFiles): Promise { const tree: TreeEntry[] = []; @@ -100,9 +125,18 @@ export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBu } } + // A special file inside a skill is reported when that skill is built; one outside every skill (a symlinked + // skill folder, say) is reported here. A root SKILL.md is reported in the loop below. + const skillDirs = new Set(tree.filter(entry => isManifest(entry)).map(entry => dirname(entry.path))); + for (const entry of tree) { + if (entry.kind === 'unsupported' && !isManifest(entry) && !isBelowAny(skillDirs, entry.path)) { + report({ path: entry.path, message: `${NOT_FOLLOWED} (skipped)` }); + } + } + const built: BuiltSkill[] = []; for (const entry of tree) { - if (entry.kind !== 'file' || basename(entry.path) !== 'SKILL.md') continue; + if (!isManifest(entry)) continue; const dir = dirname(entry.path); if (dir === '') { report({ @@ -130,21 +164,18 @@ async function buildOne( const inside = tree.filter(entry => entry.path.startsWith(`${dir}/`)); const unsupported = inside.find(entry => entry.kind === 'unsupported'); if (unsupported !== undefined) { - report({ - path: unsupported.path, - message: 'symlinks and other special files are not followed; copy the file instead (skill skipped)' - }); + report({ path: unsupported.path, message: `${NOT_FOLLOWED} (skill skipped)` }); return undefined; } const inputs: Array<{ path: string; uri: string; bytes: Uint8Array }> = []; for (const entry of inside) { - if (entry.kind !== 'file') continue; - const uri = uriOf(entry.path); - if (!isRoundTripUri(uri)) { + // Folders too: an empty one's name appears only in its parent's directory listing. + const uri = representableUri(uriOf, entry.path); + if (uri === undefined) { report({ path: entry.path, message: 'this name cannot be represented in a URI (skill skipped)' }); return undefined; } - inputs.push({ path: entry.path, uri, bytes: await readFile(files, entry.path) }); + if (entry.kind === 'file') inputs.push({ path: entry.path, uri, bytes: await readFile(files, entry.path) }); } const manifestUri = uriOf(`${dir}/SKILL.md`); const { skill, problems } = await buildSkillEntry(manifestUri, inputs); diff --git a/packages/server/src/ext/skills/files.ts b/packages/server/src/ext/skills/files.ts index d497db9fc9..22002ec16b 100644 --- a/packages/server/src/ext/skills/files.ts +++ b/packages/server/src/ext/skills/files.ts @@ -11,27 +11,61 @@ export function splitRelativePath(path: string, label: string): string[] { return segments; } -/** An in-memory adapter. The record is read on every call, so `reload()` sees later changes to it. */ +/** A memory record's directories and files, by normalised path. */ +interface MemoryIndex { + /** Children of every directory (`''` is the root), in record order. */ + readonly dirs: ReadonlyMap>; + /** The record key that holds each file. */ + readonly files: ReadonlyMap; +} + +function indexRecord(keys: readonly string[]): MemoryIndex { + const dirs = new Map>(); + const files = new Map(); + for (const key of keys) { + const segments = splitRelativePath(key, 'file path'); + let dir = ''; + for (const [depth, name] of segments.entries()) { + let children = dirs.get(dir); + if (children === undefined) { + children = new Map(); + dirs.set(dir, children); + } + children.set(name, { name, kind: depth < segments.length - 1 ? 'directory' : 'file' }); + dir = dir === '' ? name : `${dir}/${name}`; + } + files.set(dir, key); + } + return { dirs, files }; +} + +/** + * An in-memory adapter. The record is read on every call, so `reload()` sees later changes to it. Calls stay cheap + * on large records: `read` looks the path up directly, and the index behind `list` is rebuilt only when the + * record's keys change. + */ export function memorySkillFiles(files: Record): SkillFiles { - const current = (): Map => { - const entries = new Map(); - for (const [path, content] of Object.entries(files)) entries.set(splitRelativePath(path, 'file path').join('/'), content); - return entries; + let indexedKeys: readonly string[] = []; + let index = indexRecord(indexedKeys); + const current = (): MemoryIndex => { + const keys = Object.keys(files); + if (keys.length !== indexedKeys.length || keys.some((key, position) => key !== indexedKeys[position])) { + index = indexRecord(keys); + indexedKeys = keys; + } + return index; }; return { async list(dir) { - const prefix = dir === '' ? '' : `${dir}/`; - const children = new Map(); - for (const path of current().keys()) { - if (!path.startsWith(prefix)) continue; - const [name, ...deeper] = path.slice(prefix.length).split('/'); - if (name !== undefined) children.set(name, { name, kind: deeper.length > 0 ? 'directory' : 'file' }); - } - if (dir !== '' && children.size === 0) throw new Error(`no such directory: ${dir}`); - return [...children.values()]; + const children = current().dirs.get(dir); + if (children !== undefined) return Array.from(children.values(), entry => ({ ...entry })); + if (dir === '') return []; + throw new Error(`no such directory: ${dir}`); }, async read(path) { - const content = current().get(path); + // Keys written as `./a/b` or `a//b` are found through the index. + const key = Object.hasOwn(files, path) ? path : current().files.get(path); + const content = key === undefined ? undefined : files[key]; if (content === undefined) throw new Error(`no such file: ${path}`); return typeof content === 'string' ? encodeUtf8(content) : content; } diff --git a/packages/server/test/ext/skills/catalogBuild.test.ts b/packages/server/test/ext/skills/catalogBuild.test.ts index 6a366d9d4b..05778b9d82 100644 --- a/packages/server/test/ext/skills/catalogBuild.test.ts +++ b/packages/server/test/ext/skills/catalogBuild.test.ts @@ -6,7 +6,7 @@ import type { CatalogBuildOptions } from '../../../src/ext/skills/catalogBuild'; import { buildCatalogSnapshot } from '../../../src/ext/skills/catalogBuild'; import { SkillCatalogError } from '../../../src/ext/skills/errors'; import { memorySkillFiles } from '../../../src/ext/skills/files'; -import type { SkillFiles, SkillWarning } from '../../../src/ext/skills/types'; +import type { SkillFileEntry, SkillFiles, SkillWarning } from '../../../src/ext/skills/types'; import { ESCALATION, fixtureFiles, GIT, LOGO, PNG, PURCHASE_ORDER, REFUNDS, REMBOURSE } from './fixtures'; function optionsWith(overrides: Partial = {}) { @@ -21,7 +21,36 @@ function optionsWith(overrides: Partial = {}) { return { warnings, options }; } +/** The problems a strict build fails with, sorted by path; `[]` when it succeeds. Any other error fails the test. */ +async function strictProblems(files: SkillFiles): Promise { + try { + await buildCatalogSnapshot(files, optionsWith({ strict: true }).options); + return []; + } catch (error) { + if (error instanceof SkillCatalogError) return error.problems.toSorted(byPath); + throw error; + } +} + +/** `files` plus extra entries in some directories, the way a disk adapter reports symlinks and empty folders. */ +function withEntries(files: SkillFiles, extra: Record): SkillFiles { + return { + async list(dir) { + if (!Object.hasOwn(extra, dir)) return files.list(dir); + const existing = await files.list(dir).catch((): SkillFileEntry[] => []); + return [...existing, ...extra[dir]!]; + }, + read: path => files.read(path) + }; +} + +const byPath = (a: SkillWarning, b: SkillWarning): number => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0); +const names = (entries: readonly SkillFileEntry[]): string[] => entries.map(entry => entry.name).toSorted(); +const text = (bytes: Uint8Array): string => new TextDecoder().decode(bytes); + const INVALID = '---\nname: other\ndescription: d\n---\n'; +const NOT_FOLLOWED = /symlinks and other special files are not followed; copy the file instead/; +const UNREPRESENTABLE = /cannot be represented in a URI/; describe('buildCatalogSnapshot', () => { it('builds flat, URI-sorted entries; a nested skill is also a file of the enclosing skill', async () => { @@ -107,6 +136,50 @@ describe('buildCatalogSnapshot', () => { expect(warnings).toContainEqual(expect.objectContaining({ path: 'git-workflow/run.sh' })); }); + it('skips a skill whose SKILL.md is a symlink, or fails when strict', async () => { + const files = withEntries(memorySkillFiles({ ...fixtureFiles(), 'linked/README.md': '# Linked\n' }), { + linked: [{ name: 'SKILL.md', kind: 'unsupported' }] + }); + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(files, options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + const expected = [{ path: 'linked/SKILL.md', message: expect.stringMatching(NOT_FOLLOWED) }]; + expect(warnings).toEqual(expected); + expect(await strictProblems(files)).toEqual(expected); + }); + + it('reports a symlink outside any skill, such as a linked skill folder, or fails when strict', async () => { + const files = withEntries(memorySkillFiles(fixtureFiles()), { + '': [{ name: 'shared-skill', kind: 'unsupported' }], + 'acme/billing': [{ name: 'policy.md', kind: 'unsupported' }] + }); + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(files, options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + const expected = ['acme/billing/policy.md', 'shared-skill'].map(path => ({ path, message: expect.stringMatching(NOT_FOLLOWED) })); + expect(warnings.toSorted(byPath)).toEqual(expected); + expect(await strictProblems(files)).toEqual(expected); + }); + + it('skips a skill with a name that cannot be represented in a URI, such as a lone surrogate', async () => { + const memory = memorySkillFiles({ + ...fixtureFiles(), + 'bad/SKILL.md': '---\nname: bad\ndescription: d\n---\n', + 'bad/x\uD800.md': 'x' + }); + // An empty folder too: no file below it carries the name, yet the catalog lists the folder. + const files = withEntries(memory, { 'git-workflow': [{ name: 'empty\uDC00', kind: 'directory' }], 'git-workflow/empty\uDC00': [] }); + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(files, options); + expect(snapshot.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION]); + const expected = ['bad/x\uD800.md', 'git-workflow/empty\uDC00'].map(path => ({ + path, + message: expect.stringMatching(UNREPRESENTABLE) + })); + expect(warnings.toSorted(byPath)).toEqual(expected); + expect(await strictProblems(files)).toEqual(expected); + }); + it('collects every problem into one SkillCatalogError when strict', async () => { const files = { ...fixtureFiles(), 'bad/SKILL.md': INVALID, 'SKILL.md': '---\nname: r\ndescription: d\n---\n' }; const error = await buildCatalogSnapshot(memorySkillFiles(files), optionsWith({ strict: true }).options).catch( @@ -153,4 +226,39 @@ describe('memorySkillFiles', () => { await expect(files.list('missing')).rejects.toThrow(); await expect(files.read('a/missing.md')).rejects.toThrow(); }); + + it('sees additions, removals and new content after earlier calls', async () => { + const record: Record = { 'a/SKILL.md': 'x' }; + const files = memorySkillFiles(record); + expect(names(await files.list('a'))).toEqual(['SKILL.md']); + // Same number of keys as before, different keys. + record['a/b/c.md'] = 'c'; + delete record['a/SKILL.md']; + expect(names(await files.list('a'))).toEqual(['b']); + await expect(files.read('a/SKILL.md')).rejects.toThrow(); + record['a/b/c.md'] = 'changed'; + expect(text(await files.read('a/b/c.md'))).toBe('changed'); + }); + + it('finds files stored under keys that are not normalised', async () => { + const files = memorySkillFiles({ './a/SKILL.md': 'x', 'a//b/c.md': 'c' }); + expect(names(await files.list(''))).toEqual(['a']); + expect(names(await files.list('a'))).toEqual(['SKILL.md', 'b']); + expect(text(await files.read('a/b/c.md'))).toBe('c'); + }); + + it('loads a catalog of a few thousand files quickly', async () => { + const record: Record = {}; + for (let index = 0; index < 600; index++) { + const name = `skill-${index}`; + record[`${name}/SKILL.md`] = `---\nname: ${name}\ndescription: d\n---\n`; + for (const file of ['a.md', 'b.md', 'c.md', 'd.md']) record[`${name}/${file}`] = file; + } + const started = performance.now(); + const snapshot = await buildCatalogSnapshot(memorySkillFiles(record), optionsWith().options); + const elapsed = performance.now() - started; + expect(snapshot.ordered).toHaveLength(600); + // A generous bound: about 0.2 s here, against about 4 s when every call re-normalised every key. + expect(elapsed).toBeLessThan(1500); + }); }); From 73431e2a19a37f36e19e9bc1fc1c88616de16ff7 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 05:42:05 +0300 Subject: [PATCH 12/45] perf(server): build skill catalogs in near-linear time memorySkillFiles re-reads its record once per walk (on list(''), where every load and reload() starts) instead of on every call, and the catalog build indexes the walked tree once instead of rescanning it per skill and per directory. Output and ordering are unchanged; 10,000 files build in about 0.1 s instead of several seconds. --- .../server/src/ext/skills/catalogBuild.ts | 39 ++++++++------ packages/server/src/ext/skills/files.ts | 21 +++----- .../test/ext/skills/catalogBuild.test.ts | 51 ++++++++++++------- 3 files changed, 66 insertions(+), 45 deletions(-) diff --git a/packages/server/src/ext/skills/catalogBuild.ts b/packages/server/src/ext/skills/catalogBuild.ts index a850db88a9..3f201c9864 100644 --- a/packages/server/src/ext/skills/catalogBuild.ts +++ b/packages/server/src/ext/skills/catalogBuild.ts @@ -41,6 +41,8 @@ interface BuiltSkill { readonly skill: Skill; readonly files: readonly CatalogFile[]; readonly manifestSize: number; + /** Every entry below the skill's folder, nested skills' included, in tree order. */ + readonly inside: readonly TreeEntry[]; } type UriOf = (path: string) => string; @@ -59,12 +61,11 @@ const NOT_FOLLOWED = 'symlinks and other special files are not followed; copy th /** Any non-directory named exactly `SKILL.md` marks a skill, so a symlinked one is reported rather than ignored. */ const isManifest = (entry: TreeEntry): boolean => entry.kind !== 'directory' && basename(entry.path) === 'SKILL.md'; -/** True when some folder above `path` is one of `dirs`. */ -function isBelowAny(dirs: ReadonlySet, path: string): boolean { - for (let dir = dirname(path); dir !== ''; dir = dirname(dir)) { - if (dirs.has(dir)) return true; - } - return false; +/** Appends `value` to the list kept under `key`. */ +function append(lists: Map, key: K, value: V): void { + const list = lists.get(key); + if (list === undefined) lists.set(key, [value]); + else list.push(value); } /** The URI of `path`, or `undefined` when a name in it has no URI form (a lone surrogate, or no round trip). */ @@ -125,11 +126,19 @@ export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBu } } + // One pass over each entry's folders collects every skill's entries (nested skills' included), in tree order. // A special file inside a skill is reported when that skill is built; one outside every skill (a symlinked // skill folder, say) is reported here. A root SKILL.md is reported in the loop below. const skillDirs = new Set(tree.filter(entry => isManifest(entry)).map(entry => dirname(entry.path))); + const insideOf = new Map(); for (const entry of tree) { - if (entry.kind === 'unsupported' && !isManifest(entry) && !isBelowAny(skillDirs, entry.path)) { + let inSkill = false; + for (let dir = dirname(entry.path); dir !== ''; dir = dirname(dir)) { + if (!skillDirs.has(dir)) continue; + inSkill = true; + append(insideOf, dir, entry); + } + if (!inSkill && entry.kind === 'unsupported' && !isManifest(entry)) { report({ path: entry.path, message: `${NOT_FOLLOWED} (skipped)` }); } } @@ -145,7 +154,7 @@ export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBu }); continue; } - const skill = await buildOne(files, tree, dir, uriOf, options, report); + const skill = await buildOne(files, insideOf.get(dir) ?? [], dir, uriOf, options, report); if (skill !== undefined) built.push(skill); } if (problems.length > 0) throw new SkillCatalogError(problems); @@ -155,13 +164,12 @@ export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBu async function buildOne( files: SkillFiles, - tree: readonly TreeEntry[], + inside: readonly TreeEntry[], dir: string, uriOf: UriOf, options: CatalogBuildOptions, report: (warning: SkillWarning) => void ): Promise { - const inside = tree.filter(entry => entry.path.startsWith(`${dir}/`)); const unsupported = inside.find(entry => entry.kind === 'unsupported'); if (unsupported !== undefined) { report({ path: unsupported.path, message: `${NOT_FOLLOWED} (skill skipped)` }); @@ -202,7 +210,7 @@ async function buildOne( mimeType: mimeTypeFor(basename(input.path)) })); const manifestSize = inputs.find(input => input.uri === manifestUri)?.bytes.byteLength ?? 0; - return { dir, skill, files: catalogFiles, manifestSize }; + return { dir, skill, files: catalogFiles, manifestSize, inside }; } function assemble(built: readonly BuiltSkill[], tree: readonly TreeEntry[], uriOf: UriOf): CatalogSnapshot { @@ -212,15 +220,16 @@ function assemble(built: readonly BuiltSkill[], tree: readonly TreeEntry[], uriO for (const item of built) { for (const file of item.files) files.set(file.uri, file); dirPaths.add(item.dir); - for (const entry of tree) { - if (entry.kind === 'directory' && entry.path.startsWith(`${item.dir}/`)) dirPaths.add(entry.path); + for (const entry of item.inside) { + if (entry.kind === 'directory') dirPaths.add(entry.path); } } + const childrenOf = new Map(); + for (const entry of tree) append(childrenOf, dirname(entry.path), entry); const dirs = new Map(); for (const dirPath of dirPaths) { const children: Resource[] = []; - for (const entry of tree) { - if (dirname(entry.path) !== dirPath) continue; + for (const entry of childrenOf.get(dirPath) ?? []) { if (entry.kind === 'directory') { children.push({ uri: uriOf(entry.path), name: basename(entry.path), mimeType: DIRECTORY_MIME_TYPE }); continue; diff --git a/packages/server/src/ext/skills/files.ts b/packages/server/src/ext/skills/files.ts index 22002ec16b..3a16b8276e 100644 --- a/packages/server/src/ext/skills/files.ts +++ b/packages/server/src/ext/skills/files.ts @@ -40,31 +40,26 @@ function indexRecord(keys: readonly string[]): MemoryIndex { } /** - * An in-memory adapter. The record is read on every call, so `reload()` sees later changes to it. Calls stay cheap - * on large records: `read` looks the path up directly, and the index behind `list` is rebuilt only when the - * record's keys change. + * An in-memory adapter. The record is re-read whenever a walk starts — `list('')` — so `reload()` sees later changes. + * Other calls use the index from that read (built on first use if no walk has started), so their cost does not grow + * with the record. File contents are always read live. */ export function memorySkillFiles(files: Record): SkillFiles { - let indexedKeys: readonly string[] = []; - let index = indexRecord(indexedKeys); - const current = (): MemoryIndex => { - const keys = Object.keys(files); - if (keys.length !== indexedKeys.length || keys.some((key, position) => key !== indexedKeys[position])) { - index = indexRecord(keys); - indexedKeys = keys; - } + let index: MemoryIndex | undefined; + const reindex = (): MemoryIndex => { + index = indexRecord(Object.keys(files)); return index; }; return { async list(dir) { - const children = current().dirs.get(dir); + const children = (dir === '' ? reindex() : (index ?? reindex())).dirs.get(dir); if (children !== undefined) return Array.from(children.values(), entry => ({ ...entry })); if (dir === '') return []; throw new Error(`no such directory: ${dir}`); }, async read(path) { // Keys written as `./a/b` or `a//b` are found through the index. - const key = Object.hasOwn(files, path) ? path : current().files.get(path); + const key = Object.hasOwn(files, path) ? path : (index ?? reindex()).files.get(path); const content = key === undefined ? undefined : files[key]; if (content === undefined) throw new Error(`no such file: ${path}`); return typeof content === 'string' ? encodeUtf8(content) : content; diff --git a/packages/server/test/ext/skills/catalogBuild.test.ts b/packages/server/test/ext/skills/catalogBuild.test.ts index 05778b9d82..a32f22057a 100644 --- a/packages/server/test/ext/skills/catalogBuild.test.ts +++ b/packages/server/test/ext/skills/catalogBuild.test.ts @@ -217,7 +217,7 @@ describe('buildCatalogSnapshot', () => { }); describe('memorySkillFiles', () => { - it('reads the record on every call and rejects unknown paths', async () => { + it('reads the record when first used and rejects unknown paths', async () => { const record: Record = { 'a/SKILL.md': 'x' }; const files = memorySkillFiles(record); record['a/extra.md'] = 'y'; @@ -227,19 +227,32 @@ describe('memorySkillFiles', () => { await expect(files.read('a/missing.md')).rejects.toThrow(); }); - it('sees additions, removals and new content after earlier calls', async () => { + it('re-reads the record when a walk starts, and serves changed content at once', async () => { const record: Record = { 'a/SKILL.md': 'x' }; const files = memorySkillFiles(record); - expect(names(await files.list('a'))).toEqual(['SKILL.md']); - // Same number of keys as before, different keys. + expect(names(await files.list(''))).toEqual(['a']); record['a/b/c.md'] = 'c'; delete record['a/SKILL.md']; + expect(names(await files.list(''))).toEqual(['a']); expect(names(await files.list('a'))).toEqual(['b']); await expect(files.read('a/SKILL.md')).rejects.toThrow(); record['a/b/c.md'] = 'changed'; expect(text(await files.read('a/b/c.md'))).toBe('changed'); }); + it('shows edits made between two catalog builds', async () => { + const record = fixtureFiles(); + const files = memorySkillFiles(record); + const before = await buildCatalogSnapshot(files, optionsWith().options); + expect(before.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + delete record['git-workflow/SKILL.md']; + record['deploy/SKILL.md'] = '---\nname: deploy\ndescription: Deploy a release.\n---\n'; + record['acme/billing/refunds/SKILL.md'] = '---\nname: refunds\ndescription: Refund workflow, revised.\n---\n'; + const after = await buildCatalogSnapshot(files, optionsWith().options); + expect(after.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, 'skill://deploy/SKILL.md']); + expect(after.skills.get(REFUNDS)?.frontmatter.description).toBe('Refund workflow, revised.'); + }); + it('finds files stored under keys that are not normalised', async () => { const files = memorySkillFiles({ './a/SKILL.md': 'x', 'a//b/c.md': 'c' }); expect(names(await files.list(''))).toEqual(['a']); @@ -247,18 +260,22 @@ describe('memorySkillFiles', () => { expect(text(await files.read('a/b/c.md'))).toBe('c'); }); - it('loads a catalog of a few thousand files quickly', async () => { - const record: Record = {}; - for (let index = 0; index < 600; index++) { - const name = `skill-${index}`; - record[`${name}/SKILL.md`] = `---\nname: ${name}\ndescription: d\n---\n`; - for (const file of ['a.md', 'b.md', 'c.md', 'd.md']) record[`${name}/${file}`] = file; - } - const started = performance.now(); - const snapshot = await buildCatalogSnapshot(memorySkillFiles(record), optionsWith().options); - const elapsed = performance.now() - started; - expect(snapshot.ordered).toHaveLength(600); - // A generous bound: about 0.2 s here, against about 4 s when every call re-normalised every key. - expect(elapsed).toBeLessThan(1500); + it('enumerates the record once per walk, however many directories and files the walk visits', async () => { + let enumerations = 0; + // A key that is not normalised is read through the index, not by re-reading the record. + const target = { ...fixtureFiles(), './git-workflow/references/STYLE.md': '# Style\n' }; + const record = new Proxy(target, { + ownKeys(object) { + enumerations++; + return Reflect.ownKeys(object); + } + }); + const files = memorySkillFiles(record); + const snapshot = await buildCatalogSnapshot(files, optionsWith().options); + expect(snapshot.files.has('skill://git-workflow/references/STYLE.md')).toBe(true); + expect(snapshot.dirs.size).toBeGreaterThan(3); + expect(enumerations).toBe(1); + await buildCatalogSnapshot(files, optionsWith().options); + expect(enumerations).toBe(2); }); }); From 7c61b9a4ab78398517cbbc08b9b1eb6a347c1655 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 05:51:12 +0300 Subject: [PATCH 13/45] feat(server): adapter-built skill catalogs with reload SkillsExtension.load(files) builds a SkillCatalog through a two-method SkillFiles adapter (memorySkillFiles for in-code files). It finds every SKILL.md, hashes the files, checks the SEP and Agent Skills rules, skips invalid skills with a warning (or fails under strict), lists only SKILL.md in resources/list, pages with position cursors and re-checks every read against the published digest. reload() is serialized and all-or-nothing, and sends resources/list_changed when the listing changes. --- .../server/src/ext/skills/catalogSource.ts | 83 ++++++++ packages/server/src/ext/skills/extension.ts | 147 +++++++++++++- packages/server/src/ext/skills/index.ts | 15 +- packages/server/src/ext/skills/paging.ts | 57 ++++++ .../server/test/ext/skills/catalog.test.ts | 191 ++++++++++++++++++ .../server/test/ext/skills/paging.test.ts | 31 +++ 6 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/ext/skills/catalogSource.ts create mode 100644 packages/server/src/ext/skills/paging.ts create mode 100644 packages/server/test/ext/skills/catalog.test.ts create mode 100644 packages/server/test/ext/skills/paging.test.ts diff --git a/packages/server/src/ext/skills/catalogSource.ts b/packages/server/src/ext/skills/catalogSource.ts new file mode 100644 index 0000000000..c9735149b3 --- /dev/null +++ b/packages/server/src/ext/skills/catalogSource.ts @@ -0,0 +1,83 @@ +import { + canonicalUri, + decodeUtf8Lossless, + ProtocolError, + ProtocolErrorCode, + ResourceNotFoundError, + sha256Digest +} from '@modelcontextprotocol/core-internal'; + +import type { CatalogSnapshot } from './catalogBuild'; +import { trustedSources } from './internal'; +import { paginate } from './paging'; +import type { SkillFiles, SkillReadResult, SkillSource, SkillWarning } from './types'; + +/** The catalog's current snapshot; `reload()` swaps `current` in one assignment. */ +export interface CatalogState { + current: CatalogSnapshot; +} + +const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** Serves a catalog snapshot as a trusted {@linkcode SkillSource}: its entries were built and checked by the SDK. */ +export function catalogSource( + state: CatalogState, + files: SkillFiles, + pageSize: number, + warn: (warning: SkillWarning) => void +): SkillSource { + const source: SkillSource = { + async list({ cursor }) { + const page = paginate(state.current.ordered, cursor, pageSize, skill => skill.uri); + return page.nextCursor === undefined ? { skills: page.items } : { skills: page.items, nextCursor: page.nextCursor }; + }, + async get(uri) { + const canonical = canonicalUri(uri); + return canonical === undefined ? undefined : state.current.skills.get(canonical); + }, + read: uri => readChecked(state.current, files, uri, warn), + async readDirectory(uri, { cursor }) { + const canonical = canonicalUri(uri); + const children = canonical === undefined ? undefined : state.current.dirs.get(canonical); + if (children === undefined) return; + const page = paginate(children, cursor, pageSize, child => child.uri); + return page.nextCursor === undefined ? { resources: page.items } : { resources: page.items, nextCursor: page.nextCursor }; + }, + async listResources() { + return [...state.current.listed]; + } + }; + trustedSources.add(source); + return source; +} + +/** Reads a published file through the adapter and serves it only if it still matches its entry. */ +async function readChecked( + snapshot: CatalogSnapshot, + files: SkillFiles, + uri: string, + warn: (warning: SkillWarning) => void +): Promise { + const canonical = canonicalUri(uri); + const file = canonical === undefined ? undefined : snapshot.files.get(canonical); + if (file === undefined) { + if (canonical !== undefined && snapshot.dirs.has(canonical)) { + throw new ResourceNotFoundError(uri, `${uri} is a directory; use resources/directory/read`); + } + return undefined; + } + let bytes: Uint8Array; + try { + bytes = await files.read(file.path); + } catch (error) { + warn({ path: file.path, message: `could not be read: ${errorText(error)}` }); + throw new ProtocolError(ProtocolErrorCode.InternalError, `${uri} could not be read`); + } + if (bytes.byteLength !== file.size || (await sha256Digest(bytes)) !== file.digest) { + warn({ path: file.path, message: 'changed since the catalog was loaded; call reload()' }); + throw new ProtocolError(ProtocolErrorCode.InternalError, `${uri} changed since the skills catalog was loaded`); + } + // Spec §5: `text` whenever the bytes decode losslessly as UTF-8, whatever the MIME type; otherwise a base64 `blob`. + const text = decodeUtf8Lossless(bytes); + return text === undefined ? { bytes, mimeType: file.mimeType } : { text, mimeType: file.mimeType }; +} diff --git a/packages/server/src/ext/skills/extension.ts b/packages/server/src/ext/skills/extension.ts index 8fd4b3161f..72242d6b57 100644 --- a/packages/server/src/ext/skills/extension.ts +++ b/packages/server/src/ext/skills/extension.ts @@ -4,6 +4,7 @@ import { ListSkillsRequestParamsSchema, ReadResourceDirectoryRequestParamsSchema, RESOURCES_DIRECTORY_READ_METHOD, + SKILL_LIMITS, SKILLS_EXTENSION_ID, SKILLS_GET_METHOD, SKILLS_LIST_METHOD @@ -14,6 +15,7 @@ import { canonicalUri, encodeBase64, isModernProtocolVersion, + joinUri, ProtocolError, ProtocolErrorCode, ResourceNotFoundError, @@ -23,9 +25,23 @@ import { import type { McpServer } from '../../server/mcp'; import { ResourceTemplate } from '../../server/mcp'; import type { Server } from '../../server/server'; +import type { CatalogBuildOptions, CatalogSnapshot } from './catalogBuild'; +import { buildCatalogSnapshot } from './catalogBuild'; +import type { CatalogState } from './catalogSource'; +import { catalogSource } from './catalogSource'; import { defaultWarn } from './errors'; +import { splitRelativePath } from './files'; import { trustedSources } from './internal'; -import type { SkillReadResult, SkillSource, SkillSourceOptions, SkillWarning } from './types'; +import { resolvePageSize } from './paging'; +import type { + SkillCatalogChanges, + SkillCatalogOptions, + SkillFiles, + SkillReadResult, + SkillSource, + SkillSourceOptions, + SkillWarning +} from './types'; const SCHEME_URI = /^[a-z][\d+.a-z-]*:\/\//i; @@ -56,6 +72,14 @@ export class SkillsExtension { readonly #warn: (warning: SkillWarning) => void; readonly #attached = new WeakSet(); + /** + * Builds a catalog from storage through a {@linkcode SkillFiles} adapter + * (`memorySkillFiles`, `nodeSkillFiles`, or your own). + */ + static load(files: SkillFiles, options?: SkillCatalogOptions): Promise { + return SkillCatalog.load(files, options); + } + constructor(options: SkillSourceOptions) { if (options.cacheHint !== undefined) assertValidCacheHint(options.cacheHint, 'skills cacheHint'); const baseUri = options.baseUri ?? 'skill://'; @@ -181,3 +205,124 @@ export class SkillsExtension { return { resources: kept }; } } + +interface CatalogInit { + readonly state: CatalogState; + readonly files: SkillFiles; + readonly build: CatalogBuildOptions; + readonly sourceOptions: SkillSourceOptions; +} + +/** At this many tracked servers, `attach` first drops references to servers that were garbage-collected. */ +const PRUNE_THRESHOLD = 1024; + +function resolveLimits(limits: SkillCatalogOptions['limits']): { files: number; bytes: number } { + const resolved = { files: limits?.files ?? SKILL_LIMITS.files, bytes: limits?.bytes ?? SKILL_LIMITS.bytes }; + for (const [name, value] of Object.entries(resolved)) { + if (value !== Number.POSITIVE_INFINITY && !(Number.isSafeInteger(value) && value > 0)) { + throw new RangeError(`limits.${name} must be a positive integer or Infinity (got ${value})`); + } + } + return resolved; +} + +/** What `resources/list_changed` is about: which skills exist, and each one's name and description. */ +function listingKey(snapshot: CatalogSnapshot): string { + return JSON.stringify(snapshot.listed.map(resource => [resource.uri, resource.name, resource.description])); +} + +function diffSnapshots(previous: CatalogSnapshot, next: CatalogSnapshot): SkillCatalogChanges { + const same = (uri: string): boolean => JSON.stringify(previous.skills.get(uri)) === JSON.stringify(next.skills.get(uri)); + return { + added: [...next.skills.keys()].filter(uri => !previous.skills.has(uri)), + removed: [...previous.skills.keys()].filter(uri => !next.skills.has(uri)), + changed: [...next.skills.keys()].filter(uri => previous.skills.has(uri) && !same(uri)) + }; +} + +/** + * A catalog the SDK built from storage. It holds metadata only: every file + * read goes through the adapter and is checked against the published entry. + */ +export class SkillCatalog extends SkillsExtension { + readonly #state: CatalogState; + readonly #files: SkillFiles; + readonly #build: CatalogBuildOptions; + readonly #servers = new Set>(); + #queue: Promise = Promise.resolve(); + + private constructor(init: CatalogInit) { + super(init.sourceOptions); + this.#state = init.state; + this.#files = init.files; + this.#build = init.build; + } + + static override async load(files: SkillFiles, options: SkillCatalogOptions = {}): Promise { + const pageSize = resolvePageSize(options.pageSize); + const build: CatalogBuildOptions = { + prefix: options.prefix === undefined ? [] : splitRelativePath(options.prefix, 'prefix'), + limits: resolveLimits(options.limits), + strict: options.strict ?? false, + warn: options.onWarning ?? defaultWarn + }; + const state: CatalogState = { current: await buildCatalogSnapshot(files, build) }; + return new SkillCatalog({ + state, + files, + build, + sourceOptions: { + source: catalogSource(state, files, pageSize, build.warn), + baseUri: build.prefix.length === 0 ? 'skill://' : joinUri('skill://', build.prefix), + cacheHint: options.cacheHint, + strict: options.strict, + onWarning: build.warn + } + }); + } + + /** The skills this catalog serves, sorted by URI. */ + get entries(): readonly Skill[] { + return this.#state.current.ordered; + } + + /** + * Scans the storage again and swaps the new catalog in at once. All or + * nothing: on failure the old catalog keeps serving. Overlapping calls run + * one after another. Sends `resources/list_changed` to connected servers + * when skills are added or removed, or a `SKILL.md`'s name or description + * changes. + */ + reload(): Promise { + const run = this.#queue.then(() => this.#reloadNow()); + this.#queue = run.then( + () => {}, + () => {} + ); + return run; + } + + protected override afterAttach(mcp: McpServer): void { + if (this.#servers.size >= PRUNE_THRESHOLD) { + for (const reference of this.#servers) if (reference.deref() === undefined) this.#servers.delete(reference); + } + this.#servers.add(new WeakRef(mcp)); + } + + async #reloadNow(): Promise { + const previous = this.#state.current; + const next = await buildCatalogSnapshot(this.#files, this.#build); + this.#state.current = next; + if (listingKey(previous) !== listingKey(next)) this.#notify(); + return diffSnapshots(previous, next); + } + + #notify(): void { + for (const reference of this.#servers) { + const mcp = reference.deref(); + // McpServer.sendResourceListChanged does nothing while disconnected. + if (mcp === undefined) this.#servers.delete(reference); + else mcp.sendResourceListChanged(); + } + } +} diff --git a/packages/server/src/ext/skills/index.ts b/packages/server/src/ext/skills/index.ts index d7ccfa9a41..14e5891e9a 100644 --- a/packages/server/src/ext/skills/index.ts +++ b/packages/server/src/ext/skills/index.ts @@ -5,9 +5,20 @@ // (modelcontextprotocol/ext-skills#71), typescript-sdk#2818 and #2972. export { SkillCatalogError } from './errors'; -export { SkillsExtension } from './extension'; +export { SkillCatalog, SkillsExtension } from './extension'; +export { memorySkillFiles } from './files'; export { skillEntry } from './skillEntry'; -export type { SkillReadResult, SkillsOptions, SkillSource, SkillSourceOptions, SkillWarning } from './types'; +export type { + SkillCatalogChanges, + SkillCatalogOptions, + SkillFileEntry, + SkillFiles, + SkillReadResult, + SkillsOptions, + SkillSource, + SkillSourceOptions, + SkillWarning +} from './types'; export type { DirectoryResource, GetSkillResult, diff --git a/packages/server/src/ext/skills/paging.ts b/packages/server/src/ext/skills/paging.ts new file mode 100644 index 0000000000..b73d932914 --- /dev/null +++ b/packages/server/src/ext/skills/paging.ts @@ -0,0 +1,57 @@ +import { + compareUris, + decodeBase64, + decodeUtf8Lossless, + encodeBase64, + encodeUtf8, + ProtocolError, + ProtocolErrorCode +} from '@modelcontextprotocol/core-internal'; + +const CURSOR_PREFIX = 'after:'; + +/** An opaque, URL-safe cursor meaning "after this URI". */ +export function encodeCursor(uri: string): string { + return encodeBase64(encodeUtf8(CURSOR_PREFIX + uri)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); +} + +/** The URI a cursor points after, or `undefined` when it is not one of ours. */ +export function decodeCursor(cursor: string): string | undefined { + try { + const base64 = cursor.replaceAll('-', '+').replaceAll('_', '/'); + const decoded = decodeUtf8Lossless(decodeBase64(base64 + '='.repeat((4 - (base64.length % 4)) % 4))); + return decoded?.startsWith(CURSOR_PREFIX) ? decoded.slice(CURSOR_PREFIX.length) : undefined; + } catch { + return undefined; + } +} + +/** One page of URI-sorted `items` after `cursor`. Entries added or removed elsewhere never shift the boundary. */ +export function paginate( + items: readonly T[], + cursor: string | undefined, + pageSize: number, + key: (item: T) => string +): { items: T[]; nextCursor?: string } { + let start = 0; + if (cursor !== undefined) { + const after = decodeCursor(cursor); + if (after === undefined) throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid cursor'); + start = items.findIndex(item => compareUris(key(item), after) > 0); + if (start === -1) start = items.length; + } + const end = Math.min(items.length, start + pageSize); + const page = items.slice(start, end); + const last = page.at(-1); + return end < items.length && last !== undefined ? { items: page, nextCursor: encodeCursor(key(last)) } : { items: page }; +} + +/** The `pageSize` default (100) and its validation: a positive safe integer or `Infinity`. */ +export function resolvePageSize(pageSize: number | undefined): number { + if (pageSize === undefined) return 100; + if (pageSize === Number.POSITIVE_INFINITY || (Number.isSafeInteger(pageSize) && pageSize > 0)) return pageSize; + throw new RangeError(`pageSize must be a positive integer or Infinity (got ${pageSize})`); +} diff --git a/packages/server/test/ext/skills/catalog.test.ts b/packages/server/test/ext/skills/catalog.test.ts new file mode 100644 index 0000000000..84922da2d3 --- /dev/null +++ b/packages/server/test/ext/skills/catalog.test.ts @@ -0,0 +1,191 @@ +import { createHash } from 'node:crypto'; + +import type { Skill, SkillResourceEntry } from '@modelcontextprotocol/core/ext/skills'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SkillCatalogOptions, SkillWarning } from '../../../src/ext/skills/index'; +import { memorySkillFiles, SkillCatalogError, SkillsExtension } from '../../../src/ext/skills/index'; +import { ESCALATION, fixtureFiles, GIT, GIT_SKILL_MD, LOGO, PNG, PURCHASE_ORDER, REFUNDS, REFUNDS_SKILL_MD, REMBOURSE } from './fixtures'; +import { errorOf, LEGACY, newServer, resultOf } from './harness'; + +async function loaded(options: SkillCatalogOptions = {}) { + const files = fixtureFiles(); + const warnings: SkillWarning[] = []; + const catalog = await SkillsExtension.load(memorySkillFiles(files), { onWarning: warning => warnings.push(warning), ...options }); + const mcp = newServer(); + catalog.attach(mcp); + return { catalog, mcp, warnings, files }; +} + +const skillUris = (result: Record): string[] => (result['skills'] as Skill[]).map(skill => skill.uri); +const firstContent = (result: Record): Record | undefined => + (result['contents'] as Array>)[0]; +const NEW_SKILL = '---\nname: aaa\ndescription: Sorts first.\n---\n'; + +describe('SkillCatalog serving', () => { + it('loads every skill sorted by URI and declares directoryRead with the skill:// template', async () => { + const { catalog, mcp } = await loaded(); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(mcp.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': { directoryRead: true } }); + expect(await resultOf(mcp, 'resources/templates/list')).toMatchObject({ + resourceTemplates: [{ name: 'io.modelcontextprotocol/skills', uriTemplate: 'skill://{+path}' }] + }); + }); + + it('lists only SKILL.md resources in resources/list', async () => { + const { mcp } = await loaded(); + expect(await resultOf(mcp, 'resources/list')).toMatchObject({ + resources: [ + { uri: REFUNDS, name: 'refunds', mimeType: 'text/markdown' }, + { uri: ESCALATION, name: 'escalation', mimeType: 'text/markdown' }, + { uri: GIT, name: 'git-workflow', mimeType: 'text/markdown' } + ] + }); + }); + + it('pages skills/list with position cursors that survive a reload', async () => { + const { mcp, catalog, files } = await loaded({ pageSize: 2 }); + const first = await resultOf(mcp, 'skills/list'); + expect(skillUris(first)).toEqual([REFUNDS, ESCALATION]); + files['aaa/SKILL.md'] = NEW_SKILL; + await catalog.reload(); + const second = await resultOf(mcp, 'skills/list', { cursor: first['nextCursor'] }); + expect(skillUris(second)).toEqual([GIT]); + expect(second).not.toHaveProperty('nextCursor'); + }); + + it('rejects bad cursors and bad page sizes, and serves one page with Infinity', async () => { + const paged = await loaded(); + expect(await errorOf(paged.mcp, 'skills/list', { cursor: 'garbage' })).toMatchObject({ code: -32_602 }); + await expect(SkillsExtension.load(memorySkillFiles(fixtureFiles()), { pageSize: 0 })).rejects.toThrow(RangeError); + const unpaged = await loaded({ pageSize: Number.POSITIVE_INFINITY }); + expect(skillUris(await resultOf(unpaged.mcp, 'skills/list'))).toHaveLength(3); + }); + + it('answers skills/get for served skills and -32602 otherwise', async () => { + const { mcp } = await loaded(); + expect(await resultOf(mcp, 'skills/get', { uri: ESCALATION })).toMatchObject({ skill: { uri: ESCALATION } }); + expect(await errorOf(mcp, 'skills/get', { uri: 'skill://nope/SKILL.md' })).toMatchObject({ code: -32_602 }); + }); + + it('serves text, binary and Windows-authored SKILL.md files byte for byte', async () => { + const { mcp, catalog } = await loaded(); + expect(await resultOf(mcp, 'resources/read', { uri: GIT })).toMatchObject({ + contents: [{ uri: GIT, mimeType: 'text/markdown', text: GIT_SKILL_MD }] + }); + const logo = firstContent(await resultOf(mcp, 'resources/read', { uri: LOGO })); + expect(Buffer.from(String(logo?.['blob']), 'base64')).toEqual(Buffer.from(PNG)); + const refunds = firstContent(await resultOf(mcp, 'resources/read', { uri: REFUNDS })); + expect(refunds?.['text']).toBe(REFUNDS_SKILL_MD); + const entry = catalog.entries.find(skill => skill.uri === REFUNDS); + const listed = (entry?.resources as SkillResourceEntry[]).find(resource => resource.uri === REFUNDS); + expect(listed?.digest).toBe(`sha256:${createHash('sha256').update(REFUNDS_SKILL_MD).digest('hex')}`); + }); + + it('serves valid UTF-8 as text whatever the MIME type', async () => { + const { mcp, catalog, files } = await loaded(); + files['git-workflow/LICENSE'] = 'MIT License\n'; + await catalog.reload(); + const uri = 'skill://git-workflow/LICENSE'; + const license = firstContent(await resultOf(mcp, 'resources/read', { uri })); + expect(license).toEqual({ uri, mimeType: 'application/octet-stream', text: 'MIT License\n' }); + }); + + it('serves names with a space or a non-ASCII letter, however the request spells the URI', async () => { + const { mcp } = await loaded(); + for (const [requested, served, text] of [ + [PURCHASE_ORDER, PURCHASE_ORDER, '# PO\n'], + ['skill://acme/billing/refunds/templates/Purchase Order.md', PURCHASE_ORDER, '# PO\n'], + [REMBOURSE, REMBOURSE, '# Remboursement\n'], + ['skill://acme/billing/refunds/templates/Remboursé.md', REMBOURSE, '# Remboursement\n'] + ] as const) { + expect(await resultOf(mcp, 'resources/read', { uri: requested })).toMatchObject({ contents: [{ uri: served, text }] }); + } + }); + + it('answers -32602 when resources/read targets a directory', async () => { + const { mcp } = await loaded(); + expect(await errorOf(mcp, 'resources/read', { uri: 'skill://acme/billing/refunds' })).toMatchObject({ + code: -32_602, + message: expect.stringMatching(/is a directory/) + }); + }); + + it('fails a read whose file changed since loading, warning every time', async () => { + const { mcp, files, warnings } = await loaded(); + files['git-workflow/references/REVIEW.md'] = '# Changed\n'; + const uri = 'skill://git-workflow/references/REVIEW.md'; + expect(await errorOf(mcp, 'resources/read', { uri })).toMatchObject({ code: -32_603 }); + expect(await errorOf(mcp, 'resources/read', { uri })).toMatchObject({ code: -32_603 }); + expect(warnings.filter(warning => warning.message.includes('changed since'))).toHaveLength(2); + }); + + it('reads skill directories, with caching fields on 2026-07-28 only', async () => { + const { mcp } = await loaded(); + const uri = 'skill://acme/billing/refunds'; + expect(await resultOf(mcp, 'resources/directory/read', { uri })).toMatchObject({ + resources: [{ name: 'SKILL.md' }, { name: 'assets' }, { name: 'escalation' }, { name: 'templates' }], + ttlMs: 0, + cacheScope: 'private' + }); + expect(await resultOf(mcp, 'resources/directory/read', { uri }, LEGACY)).not.toHaveProperty('ttlMs'); + expect(await errorOf(mcp, 'resources/directory/read', { uri: 'skill://acme' })).toMatchObject({ code: -32_602 }); + }); + + it('omits caching fields on skills/list for 2025-era connections', async () => { + const { mcp } = await loaded(); + expect(await resultOf(mcp, 'skills/list', {}, LEGACY)).not.toHaveProperty('ttlMs'); + }); + + it('serves many servers from one catalog', async () => { + const { catalog } = await loaded(); + const servers = [newServer(), newServer(), newServer()]; + for (const server of servers) catalog.attach(server); + for (const server of servers) expect(await resultOf(server, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); + }); +}); + +describe('SkillCatalog.reload', () => { + it('returns what changed', async () => { + const { catalog, files } = await loaded(); + delete files['git-workflow/SKILL.md']; + delete files['git-workflow/references/REVIEW.md']; + files['acme/billing/refunds/templates/Purchase Order.md'] = '# PO v2\n'; + expect(await catalog.reload()).toEqual({ added: [], removed: [GIT], changed: [REFUNDS] }); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION]); + }); + + it('keeps serving the old catalog when a reload fails', async () => { + const { catalog, files, mcp } = await loaded({ strict: true }); + files['git-workflow/SKILL.md'] = '---\nname: renamed\ndescription: d\n---\n'; + await expect(catalog.reload()).rejects.toBeInstanceOf(SkillCatalogError); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(await resultOf(mcp, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); + }); + + it('runs overlapping reloads one after another', async () => { + const { catalog, files } = await loaded(); + files['aaa/SKILL.md'] = NEW_SKILL; + const [first, second] = await Promise.all([catalog.reload(), catalog.reload()]); + expect(first.added).toEqual(['skill://aaa/SKILL.md']); + expect(second.added).toEqual([]); + }); + + it('notifies each connected server once, and only when the listing changes', async () => { + const { catalog, files } = await loaded(); + const mcp = newServer(); + catalog.attach(mcp); + catalog.attach(mcp); + const notify = vi.spyOn(mcp, 'sendResourceListChanged'); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await mcp.connect(serverSide); + files['git-workflow/references/REVIEW.md'] = '# Review v2\n'; + await catalog.reload(); + expect(notify).not.toHaveBeenCalled(); + files['aaa/SKILL.md'] = NEW_SKILL; + await catalog.reload(); + expect(notify).toHaveBeenCalledTimes(1); + await mcp.close(); + }); +}); diff --git a/packages/server/test/ext/skills/paging.test.ts b/packages/server/test/ext/skills/paging.test.ts new file mode 100644 index 0000000000..7a51f6d5ea --- /dev/null +++ b/packages/server/test/ext/skills/paging.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeCursor, encodeCursor, paginate, resolvePageSize } from '../../../src/ext/skills/paging'; + +const items = ['skill://a', 'skill://b', 'skill://c']; +const identity = (item: string): string => item; + +describe('paging', () => { + it('round-trips cursors and rejects anything else', () => { + expect(decodeCursor(encodeCursor('skill://b/SKILL.md'))).toBe('skill://b/SKILL.md'); + expect(decodeCursor('not a cursor!')).toBeUndefined(); + expect(decodeCursor('garbage')).toBeUndefined(); + }); + + it('pages by position after the last URI, so inserts elsewhere do not shift the boundary', () => { + const first = paginate(items, undefined, 2, identity); + expect(first.items).toEqual(['skill://a', 'skill://b']); + expect(paginate(['skill://0', ...items], first.nextCursor, 2, identity)).toEqual({ items: ['skill://c'] }); + }); + + it('returns everything with Infinity and throws -32602 for a bad cursor', () => { + expect(paginate(items, undefined, Number.POSITIVE_INFINITY, identity)).toEqual({ items }); + expect(() => paginate(items, 'garbage', 2, identity)).toThrow(/Invalid cursor/); + }); + + it('validates page sizes', () => { + expect(resolvePageSize(undefined)).toBe(100); + expect(resolvePageSize(Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY); + for (const bad of [0, -1, 1.5, Number.NaN]) expect(() => resolvePageSize(bad)).toThrow(RangeError); + }); +}); From 4805bdac8a10083b0d3c86bf61ac7da10b3b83fb Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 06:02:01 +0300 Subject: [PATCH 14/45] fix(server): keep reload() notifications from crashing the process A connected server whose transport rejects resources/list_changed now produces a warning instead of an unhandled rejection. Adds a same-size edit test so the digest check on reads is exercised. --- packages/server/src/ext/skills/extension.ts | 26 ++++++++++-- .../server/test/ext/skills/catalog.test.ts | 40 ++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/server/src/ext/skills/extension.ts b/packages/server/src/ext/skills/extension.ts index 72242d6b57..3e45cedfc0 100644 --- a/packages/server/src/ext/skills/extension.ts +++ b/packages/server/src/ext/skills/extension.ts @@ -317,12 +317,32 @@ export class SkillCatalog extends SkillsExtension { return diffSnapshots(previous, next); } + /** + * Sends `resources/list_changed` to every connected server. A send that + * fails (a transport that rejects, say) becomes a warning: it must never + * surface as an unhandled rejection, nor stop the other servers. + */ #notify(): void { for (const reference of this.#servers) { const mcp = reference.deref(); - // McpServer.sendResourceListChanged does nothing while disconnected. - if (mcp === undefined) this.#servers.delete(reference); - else mcp.sendResourceListChanged(); + if (mcp === undefined) { + this.#servers.delete(reference); + continue; + } + if (!mcp.isConnected()) continue; + const sessionId = mcp.server.transport?.sessionId; + try { + // `Server.sendResourceListChanged` is async; `Promise.resolve` also covers a synchronous return. + Promise.resolve(mcp.server.sendResourceListChanged()).catch((error: unknown) => this.#notifyFailed(sessionId, error)); + } catch (error) { + this.#notifyFailed(sessionId, error); + } } } + + #notifyFailed(sessionId: string | undefined, error: unknown): void { + const to = sessionId === undefined ? '' : ` to session ${sessionId}`; + const reason = error instanceof Error ? error.message : String(error); + this.#build.warn({ path: 'resources/list_changed', message: `could not be sent${to}: ${reason}` }); + } } diff --git a/packages/server/test/ext/skills/catalog.test.ts b/packages/server/test/ext/skills/catalog.test.ts index 84922da2d3..9a909151a0 100644 --- a/packages/server/test/ext/skills/catalog.test.ts +++ b/packages/server/test/ext/skills/catalog.test.ts @@ -121,6 +121,15 @@ describe('SkillCatalog serving', () => { expect(warnings.filter(warning => warning.message.includes('changed since'))).toHaveLength(2); }); + it('fails a read whose content changed at the same size, by its digest', async () => { + const { mcp, files, warnings } = await loaded(); + files['git-workflow/references/REVIEW.md'] = '# Reviex\n'; + expect(await errorOf(mcp, 'resources/read', { uri: 'skill://git-workflow/references/REVIEW.md' })).toMatchObject({ + code: -32_603 + }); + expect(warnings).toContainEqual({ path: 'git-workflow/references/REVIEW.md', message: expect.stringContaining('changed since') }); + }); + it('reads skill directories, with caching fields on 2026-07-28 only', async () => { const { mcp } = await loaded(); const uri = 'skill://acme/billing/refunds'; @@ -173,11 +182,12 @@ describe('SkillCatalog.reload', () => { }); it('notifies each connected server once, and only when the listing changes', async () => { - const { catalog, files } = await loaded(); + const { catalog, files, mcp: disconnected } = await loaded(); const mcp = newServer(); catalog.attach(mcp); catalog.attach(mcp); - const notify = vi.spyOn(mcp, 'sendResourceListChanged'); + const notify = vi.spyOn(mcp.server, 'sendResourceListChanged'); + const skipped = vi.spyOn(disconnected.server, 'sendResourceListChanged'); const [, serverSide] = InMemoryTransport.createLinkedPair(); await mcp.connect(serverSide); files['git-workflow/references/REVIEW.md'] = '# Review v2\n'; @@ -186,6 +196,32 @@ describe('SkillCatalog.reload', () => { files['aaa/SKILL.md'] = NEW_SKILL; await catalog.reload(); expect(notify).toHaveBeenCalledTimes(1); + expect(skipped).not.toHaveBeenCalled(); await mcp.close(); }); + + it('reports a failed list_changed send as a warning, never an unhandled rejection', async () => { + const { catalog, files, warnings } = await loaded(); + const mcp = newServer(); + catalog.attach(mcp); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await mcp.connect(serverSide); + vi.spyOn(serverSide, 'send').mockRejectedValue(new Error('transport down')); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + files['aaa/SKILL.md'] = NEW_SKILL; + await expect(catalog.reload()).resolves.toMatchObject({ added: ['skill://aaa/SKILL.md'] }); + const sendFailed = { path: 'resources/list_changed', message: expect.stringContaining('transport down') }; + await vi.waitFor(() => expect(warnings).toContainEqual(sendFailed)); + await new Promise(resolve => setImmediate(resolve)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + await mcp.close(); + } + }); }); From ef74191d58b0be03177cfb506c8375634e2cda32 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 06:09:40 +0300 Subject: [PATCH 15/45] feat(server): load skills from a folder (ext/skills/node) loadSkills('./skills') and nodeSkillFiles(root) at a Node-only subpath, so ext/skills itself stays free of Node built-ins (pinned by barrelClean). Symlinks are never followed: a skill containing one is skipped with a warning. --- .../test/packageTopologyPins.test.ts | 5 +- packages/server/package.json | 13 +++ packages/server/src/ext/skills/node.ts | 63 +++++++++++++ packages/server/test/ext/skills/node.test.ts | 88 +++++++++++++++++++ .../server/test/server/barrelClean.test.ts | 45 ++++++++++ packages/server/tsdown.config.ts | 3 +- 6 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/ext/skills/node.ts create mode 100644 packages/server/test/ext/skills/node.test.ts diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 925b22dfe2..5bc882e8e3 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -43,8 +43,9 @@ const PUBLIC_PACKAGES: Record (relative === '' ? base : path.join(base, ...splitRelativePath(relative, 'path'))); + return { + async list(dir) { + const entries = await readdir(toFsPath(dir), { withFileTypes: true }); + return entries.map(entry => ({ name: entry.name, kind: kindOf(entry) })); + }, + async read(relative) { + // O_NOFOLLOW (where the platform has it) refuses a final symlink even if one appeared after listing. + const handle = await open(toFsPath(relative), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const stats = await handle.stat(); + if (!stats.isFile()) throw new Error(`${relative} is not a regular file`); + const buffer = await handle.readFile(); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } finally { + await handle.close(); + } + } + }; +} + +/** + * Loads every skill under `root` (a folder of skill folders, each with a + * `SKILL.md`) into a {@linkcode SkillCatalog}. Same as + * `SkillsExtension.load(nodeSkillFiles(root), options)`. + * + * @example + * ```ts + * const skills = await loadSkills('./skills'); + * skills.attach(mcp); + * ``` + */ +export function loadSkills(root: string, options?: SkillCatalogOptions): Promise { + return SkillsExtension.load(nodeSkillFiles(root), options); +} diff --git a/packages/server/test/ext/skills/node.test.ts b/packages/server/test/ext/skills/node.test.ts new file mode 100644 index 0000000000..abe0c97644 --- /dev/null +++ b/packages/server/test/ext/skills/node.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { SkillWarning } from '../../../src/ext/skills/index'; +import { memorySkillFiles, SkillCatalogError, SkillsExtension } from '../../../src/ext/skills/index'; +import { loadSkills, nodeSkillFiles } from '../../../src/ext/skills/node'; +import { ESCALATION, fixtureFiles, GIT, PNG, REFUNDS } from './fixtures'; +import { errorOf, newServer, resultOf } from './harness'; + +let root: string; + +async function writeTree(files: Record): Promise { + for (const [relative, content] of Object.entries(files)) { + const target = path.join(root, ...relative.split('/')); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + } +} + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'mcp-skills-')); + await writeTree(fixtureFiles()); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe('nodeSkillFiles', () => { + it('lists files and folders and reads exact bytes', async () => { + const files = nodeSkillFiles(root); + const entries = await files.list('acme/billing/refunds'); + expect(entries.map(entry => [entry.name, entry.kind]).toSorted()).toEqual([ + ['SKILL.md', 'file'], + ['assets', 'directory'], + ['escalation', 'directory'], + ['templates', 'directory'] + ]); + expect(await files.read('acme/billing/refunds/assets/logo.png')).toEqual(PNG); + }); + + it.skipIf(process.platform === 'win32')('reports symlinks as unsupported and never reads through them', async () => { + await symlink(path.join(root, 'git-workflow', 'references', 'REVIEW.md'), path.join(root, 'git-workflow', 'linked.md')); + const files = nodeSkillFiles(root); + expect(await files.list('git-workflow')).toContainEqual({ name: 'linked.md', kind: 'unsupported' }); + await expect(files.read('git-workflow/linked.md')).rejects.toThrow(); + }); + + it('rejects paths that leave the root', async () => { + await expect(nodeSkillFiles(root).read('../outside.md')).rejects.toThrow(TypeError); + await expect(nodeSkillFiles(root).list('acme/../..')).rejects.toThrow(TypeError); + }); +}); + +describe('loadSkills', () => { + it('builds the same entries as the in-memory adapter', async () => { + const fromDisk = await loadSkills(root); + const inMemory = await SkillsExtension.load(memorySkillFiles(fixtureFiles())); + expect(fromDisk.entries).toEqual(inMemory.entries); + expect(fromDisk.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + }); + + it.skipIf(process.platform === 'win32')('skips a skill that contains a symlink, with a warning', async () => { + await symlink(path.join(root, 'git-workflow', 'references', 'REVIEW.md'), path.join(root, 'git-workflow', 'linked.md')); + const warnings: SkillWarning[] = []; + const catalog = await loadSkills(root, { onWarning: warning => warnings.push(warning) }); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION]); + expect(warnings).toContainEqual({ path: 'git-workflow/linked.md', message: expect.stringMatching(/symlinks/) }); + }); + + it('fails with SkillCatalogError when the folder does not exist', async () => { + await expect(loadSkills(path.join(root, 'missing'))).rejects.toBeInstanceOf(SkillCatalogError); + }); + + it('refuses a file edited on disk until reload() publishes it', async () => { + const catalog = await loadSkills(root, { onWarning: () => undefined }); + const mcp = newServer(); + catalog.attach(mcp); + await writeFile(path.join(root, 'git-workflow', 'references', 'REVIEW.md'), '# Review v2\n'); + const uri = 'skill://git-workflow/references/REVIEW.md'; + expect(await errorOf(mcp, 'resources/read', { uri })).toMatchObject({ code: -32_603 }); + expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [GIT] }); + expect(await resultOf(mcp, 'resources/read', { uri })).toMatchObject({ contents: [{ uri, text: '# Review v2\n' }] }); + }); +}); diff --git a/packages/server/test/server/barrelClean.test.ts b/packages/server/test/server/barrelClean.test.ts index e248f24265..2ab2985327 100644 --- a/packages/server/test/server/barrelClean.test.ts +++ b/packages/server/test/server/barrelClean.test.ts @@ -100,3 +100,48 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => { } }); }); + +const NODE_BUILTIN_IMPORT = /\bfrom\s*["']node:|\brequire\(\s*["']node:|\bimport\(\s*["']node:/; + +/** An ESM entry and every chunk it imports, following `./` and `../` specifiers (nested entries import `../../chunk.mjs`). */ +function moduleGraphOf(entryPath: string): string[] { + const visited = new Set(); + const queue = [entryPath]; + while (queue.length > 0) { + const file = queue.shift()!; + if (visited.has(file)) continue; + visited.add(file); + for (const m of readFileSync(file, 'utf8').matchAll(/from\s*["'](\.{1,2}\/[^"']+?\.mjs)["']/g)) { + queue.push(join(dirname(file), m[1]!)); + } + } + return [...visited]; +} + +describe('@modelcontextprotocol/server/ext/skills is runtime-neutral', () => { + beforeAll(async () => { + await ensureBuilt(pkgDir); + }, 180_000); + + test('ext/skills and every chunk it imports have no node: imports', () => { + for (const file of moduleGraphOf(join(distDir, 'ext/skills/index.mjs'))) { + expect({ file, content: readFileSync(file, 'utf8') }).not.toEqual( + expect.objectContaining({ content: expect.stringMatching(NODE_BUILTIN_IMPORT) }) + ); + } + }); + + test('ext/skills/node exports the folder adapter and is where the node: imports live', () => { + const node = join(distDir, 'ext/skills/node.mjs'); + expect(rootExportBlockOf(readFileSync(node, 'utf8'))).toMatch( + /\bloadSkills\b[\s\S]*\bnodeSkillFiles\b|\bnodeSkillFiles\b[\s\S]*\bloadSkills\b/ + ); + expect(moduleGraphOf(node).some(file => NODE_BUILTIN_IMPORT.test(readFileSync(file, 'utf8')))).toBe(true); + }); + + test('the root entry does not export the skills extension', () => { + expect(rootExportBlockOf(readFileSync(join(distDir, 'index.mjs'), 'utf8'))).not.toMatch( + /\b(?:SkillsExtension|SkillCatalog|loadSkills|nodeSkillFiles)\b/ + ); + }); +}); diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index e1163abf6f..78bfcb33a7 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -10,7 +10,8 @@ export default defineConfig({ 'src/shimsBrowser.ts', 'src/validators/ajv.ts', 'src/validators/cfWorker.ts', - 'src/ext/skills/index.ts' + 'src/ext/skills/index.ts', + 'src/ext/skills/node.ts' ], format: ['esm', 'cjs'], fixedExtension: true, From e934057b2265d250e79d58c7b6c1176916300075 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 06:18:32 +0300 Subject: [PATCH 16/45] test(server): one module-graph walker for the runtime-neutrality pins barrelClean follows relative from- and side-effect imports with a single walker and pins the root entry and ext/skills against every node: import. Adds a real-filesystem test for a symlinked skill folder and narrows the nodeSkillFiles doc to what O_NOFOLLOW guarantees. --- packages/server/src/ext/skills/node.ts | 8 +-- packages/server/test/ext/skills/node.test.ts | 17 +++++++ .../server/test/server/barrelClean.test.ts | 51 +++++++------------ 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/server/src/ext/skills/node.ts b/packages/server/src/ext/skills/node.ts index 0c15ad2a5d..0a963fb2b4 100644 --- a/packages/server/src/ext/skills/node.ts +++ b/packages/server/src/ext/skills/node.ts @@ -20,9 +20,11 @@ function kindOf(entry: Dirent): SkillFileEntry['kind'] { } /** - * A {@linkcode SkillFiles} adapter over a folder. Symlinks and other special - * files are listed as `unsupported` (the catalog then skips the skill that - * contains one) and are never read through. + * A {@linkcode SkillFiles} adapter over a folder. `list` reports symlinks and + * other special files as `unsupported`, so a catalog skips the skill that + * contains one. `read` refuses anything that is not a regular file, and a + * symlink as the final path component where the platform has `O_NOFOLLOW`; + * symlinked folders earlier in a path passed to it directly are not checked. */ export function nodeSkillFiles(root: string): SkillFiles { const base = path.resolve(root); diff --git a/packages/server/test/ext/skills/node.test.ts b/packages/server/test/ext/skills/node.test.ts index abe0c97644..5da9299862 100644 --- a/packages/server/test/ext/skills/node.test.ts +++ b/packages/server/test/ext/skills/node.test.ts @@ -71,6 +71,23 @@ describe('loadSkills', () => { expect(warnings).toContainEqual({ path: 'git-workflow/linked.md', message: expect.stringMatching(/symlinks/) }); }); + it.skipIf(process.platform === 'win32')('skips a symlinked skill folder at the root, with a warning, and loads the rest', async () => { + const outside = await mkdtemp(path.join(tmpdir(), 'mcp-skills-linked-')); + try { + await writeFile( + path.join(outside, 'SKILL.md'), + '---\nname: linked-skill\ndescription: Lives outside the root.\n---\n# Linked\n' + ); + await symlink(outside, path.join(root, 'linked-skill'), 'dir'); + const warnings: SkillWarning[] = []; + const catalog = await loadSkills(root, { onWarning: warning => warnings.push(warning) }); + expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + expect(warnings).toContainEqual({ path: 'linked-skill', message: expect.stringMatching(/symlinks.*\(skipped\)/) }); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + it('fails with SkillCatalogError when the folder does not exist', async () => { await expect(loadSkills(path.join(root, 'missing'))).rejects.toBeInstanceOf(SkillCatalogError); }); diff --git a/packages/server/test/server/barrelClean.test.ts b/packages/server/test/server/barrelClean.test.ts index 2ab2985327..cbf2300ddb 100644 --- a/packages/server/test/server/barrelClean.test.ts +++ b/packages/server/test/server/barrelClean.test.ts @@ -14,20 +14,25 @@ const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\ // Anchored at start-of-line so JSDoc-example `from 'ajv'` strings in vendored chunks don't match. const VALIDATOR_BACKEND_IMPORT = /^import[^\n]*?from\s+["'](?:ajv|ajv-formats|@cfworker\/json-schema)["']/m; const ROOT_VALIDATOR_EXPORTS = ['AjvJsonSchemaValidator', 'CfWorkerJsonSchemaValidator', 'CfWorkerSchemaDraft']; - -function chunkImportsOf(entryPath: string): string[] { +// Static (`from "node:x"`), side-effect (`import "node:x"`, what rolldown keeps for an external whose bindings were +// tree-shaken) and dynamic `node:` imports. Only `.mjs` files are scanned, so `require(` is not matched. +const NODE_BUILTIN_IMPORT = /\b(?:from|import)\s*["']node:|\bimport\s*\(\s*["']node:/; + +/** + * An ESM entry and every chunk it imports, following relative `from` and side-effect `import` specifiers, both + * `./` and `../` (nested entries import `../../chunk.mjs`, and side-effect-only `import "../../chunk.mjs"`). + */ +function moduleGraphOf(entryPath: string): string[] { const visited = new Set(); const queue = [entryPath]; while (queue.length > 0) { const file = queue.shift()!; if (visited.has(file)) continue; visited.add(file); - const src = readFileSync(file, 'utf8'); - for (const m of src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)) { + for (const m of readFileSync(file, 'utf8').matchAll(/\b(?:from|import)\s*["'](\.{1,2}\/[^"']+?\.mjs)["']/g)) { queue.push(join(dirname(file), m[1]!)); } } - visited.delete(entryPath); return [...visited]; } @@ -54,9 +59,8 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => { }); test('chunks transitively imported by dist/index.mjs contain no process-stdio runtime imports', () => { - const entry = join(distDir, 'index.mjs'); - for (const chunk of chunkImportsOf(entry)) { - expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( + for (const file of moduleGraphOf(join(distDir, 'index.mjs'))) { + expect({ file, content: readFileSync(file, 'utf8') }).not.toEqual( expect.objectContaining({ content: expect.stringMatching(NODE_ONLY) }) ); } @@ -69,11 +73,9 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => { test('runtime shims vendor default validator backends instead of requiring consumers to install them', () => { for (const shim of ['shimsNode.mjs', 'shimsWorkerd.mjs', 'shimsBrowser.mjs']) { - const entry = join(distDir, shim); - expect(readFileSync(entry, 'utf8')).not.toMatch(VALIDATOR_BACKEND_IMPORT); - - for (const chunk of chunkImportsOf(entry)) { - expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( + // The graph includes the shim entry itself. + for (const file of moduleGraphOf(join(distDir, shim))) { + expect({ file, content: readFileSync(file, 'utf8') }).not.toEqual( expect.objectContaining({ content: expect.stringMatching(VALIDATOR_BACKEND_IMPORT) }) ); } @@ -101,30 +103,13 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => { }); }); -const NODE_BUILTIN_IMPORT = /\bfrom\s*["']node:|\brequire\(\s*["']node:|\bimport\(\s*["']node:/; - -/** An ESM entry and every chunk it imports, following `./` and `../` specifiers (nested entries import `../../chunk.mjs`). */ -function moduleGraphOf(entryPath: string): string[] { - const visited = new Set(); - const queue = [entryPath]; - while (queue.length > 0) { - const file = queue.shift()!; - if (visited.has(file)) continue; - visited.add(file); - for (const m of readFileSync(file, 'utf8').matchAll(/from\s*["'](\.{1,2}\/[^"']+?\.mjs)["']/g)) { - queue.push(join(dirname(file), m[1]!)); - } - } - return [...visited]; -} - -describe('@modelcontextprotocol/server/ext/skills is runtime-neutral', () => { +describe('@modelcontextprotocol/server root and ext/skills are runtime-neutral', () => { beforeAll(async () => { await ensureBuilt(pkgDir); }, 180_000); - test('ext/skills and every chunk it imports have no node: imports', () => { - for (const file of moduleGraphOf(join(distDir, 'ext/skills/index.mjs'))) { + test.each(['index.mjs', 'ext/skills/index.mjs'])('%s and every chunk it imports have no node: imports', entry => { + for (const file of moduleGraphOf(join(distDir, entry))) { expect({ file, content: readFileSync(file, 'utf8') }).not.toEqual( expect.objectContaining({ content: expect.stringMatching(NODE_BUILTIN_IMPORT) }) ); From 8fb3e69c48e80e55c51086d656d391089480ae73 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 06:30:15 +0300 Subject: [PATCH 17/45] feat(client): SkillsClient list, get and readDirectory new SkillsClient(client) reports supported and directoryRead, lists every page (bounded by listMaxPages, stopping at a repeated cursor) with invalid entries and every copy of a repeated URI returned in invalid (or thrown under strict), gets one entry and reads skill directories. Replaces listSkills and getSkill from #2818. Co-authored-by: Matt Carey --- .../client/src/ext/skills/clientInternals.ts | 22 +++ packages/client/src/ext/skills/entries.ts | 51 +++++ packages/client/src/ext/skills/index.ts | 115 ++--------- packages/client/src/ext/skills/pageSchemas.ts | 17 ++ .../client/src/ext/skills/skillsClient.ts | 164 ++++++++++++++++ .../test/ext/skills/clientInternals.test.ts | 19 ++ packages/client/test/ext/skills/fixtures.ts | 46 +++++ packages/client/test/ext/skills/scripted.ts | 120 ++++++++++++ .../client/test/ext/skills/skills.test.ts | 151 --------------- .../test/ext/skills/skillsClient.test.ts | 180 ++++++++++++++++++ 10 files changed, 640 insertions(+), 245 deletions(-) create mode 100644 packages/client/src/ext/skills/clientInternals.ts create mode 100644 packages/client/src/ext/skills/entries.ts create mode 100644 packages/client/src/ext/skills/pageSchemas.ts create mode 100644 packages/client/src/ext/skills/skillsClient.ts create mode 100644 packages/client/test/ext/skills/clientInternals.test.ts create mode 100644 packages/client/test/ext/skills/fixtures.ts create mode 100644 packages/client/test/ext/skills/scripted.ts delete mode 100644 packages/client/test/ext/skills/skills.test.ts create mode 100644 packages/client/test/ext/skills/skillsClient.test.ts diff --git a/packages/client/src/ext/skills/clientInternals.ts b/packages/client/src/ext/skills/clientInternals.ts new file mode 100644 index 0000000000..b5e7bc1433 --- /dev/null +++ b/packages/client/src/ext/skills/clientInternals.ts @@ -0,0 +1,22 @@ +import type { Client } from '../../client/client'; + +/** Mirrors `DEFAULT_LIST_MAX_PAGES` in `client/client.ts`. */ +const DEFAULT_LIST_MAX_PAGES = 64; + +interface ClientPrivates { + _enforceStrictCapabilities?: boolean; + _listMaxPages?: number; +} + +/** + * The two Client settings the skills client follows. Client keeps them + * private; this reads them by name, and `clientInternals.test.ts` fails if + * they are renamed. + */ +export function clientSettings(client: Client): { enforceStrictCapabilities: boolean; listMaxPages: number } { + const privates = client as unknown as ClientPrivates; + return { + enforceStrictCapabilities: privates._enforceStrictCapabilities ?? false, + listMaxPages: privates._listMaxPages ?? DEFAULT_LIST_MAX_PAGES + }; +} diff --git a/packages/client/src/ext/skills/entries.ts b/packages/client/src/ext/skills/entries.ts new file mode 100644 index 0000000000..59281d57d6 --- /dev/null +++ b/packages/client/src/ext/skills/entries.ts @@ -0,0 +1,51 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SkillSchema } from '@modelcontextprotocol/core/ext/skills'; +import { canonicalUri, validateSkillEntry } from '@modelcontextprotocol/core-internal'; + +/** A `skills/list` entry the client refused, and why. `index` counts across every page walked. */ +export interface InvalidSkillEntry { + index: number; + uri?: string; + reason: string; +} + +export interface CheckedEntry { + skill?: Skill; + uri?: string; + problems: string[]; +} + +/** Schema and rule checks for one entry received from a server. */ +export function checkEntry(value: unknown): CheckedEntry { + const raw = (value as { uri?: unknown } | null | undefined)?.uri; + const uri = typeof raw === 'string' ? raw : undefined; + const parsed = SkillSchema.safeParse(value); + if (!parsed.success) { + return { uri, problems: parsed.error.issues.map(issue => `${issue.path.join('.') || '(entry)'}: ${issue.message}`) }; + } + return { skill: parsed.data, uri, problems: validateSkillEntry(parsed.data) }; +} + +function keyOf(entry: CheckedEntry): string | undefined { + return entry.uri === undefined ? undefined : (canonicalUri(entry.uri) ?? entry.uri); +} + +/** Splits listing entries into valid skills and refused entries. Every copy of a repeated URI is refused. */ +export function partitionEntries(values: readonly unknown[]): { skills: Skill[]; invalid: InvalidSkillEntry[] } { + const checked = values.map(value => checkEntry(value)); + const counts = new Map(); + for (const entry of checked) { + const key = keyOf(entry); + if (key !== undefined) counts.set(key, (counts.get(key) ?? 0) + 1); + } + const skills: Skill[] = []; + const invalid: InvalidSkillEntry[] = []; + for (const [index, entry] of checked.entries()) { + const key = keyOf(entry); + const repeated = key !== undefined && (counts.get(key) ?? 0) > 1; + const problems = repeated ? [...entry.problems, 'the same uri is listed more than once'] : entry.problems; + if (entry.skill !== undefined && problems.length === 0) skills.push(entry.skill); + else invalid.push({ index, ...(entry.uri !== undefined && { uri: entry.uri }), reason: problems.join('; ') }); + } + return { skills, invalid }; +} diff --git a/packages/client/src/ext/skills/index.ts b/packages/client/src/ext/skills/index.ts index 21114b9b06..74ead1b823 100644 --- a/packages/client/src/ext/skills/index.ts +++ b/packages/client/src/ext/skills/index.ts @@ -1,105 +1,32 @@ // @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. +// Host side of the MCP Skills extension (SEP-2640). Adapted, with +// attribution, from the Apache-2.0 `@olaservo/ext-skills` prototype +// (modelcontextprotocol/ext-skills#71), typescript-sdk#2818 and #2972. -import type { - GetSkillRequestParams, +export type { InvalidSkillEntry } from './entries'; +export type { SkillListing, SkillsClientOptions } from './skillsClient'; +export { SkillsClient } from './skillsClient'; +export type { + DirectoryResource, GetSkillResult, - ListSkillsRequestParams, ListSkillsResult, + ReadResourceDirectoryResult, + Skill, + SkillCacheScope, + SkillFrontmatter, + SkillResourceEntry, + SkillResources, SkillsCapability } from '@modelcontextprotocol/core/ext/skills'; -import { - GetSkillResultSchema, - ListSkillsResultSchema, +export { + DIRECTORY_MIME_TYPE, + RESOURCES_DIRECTORY_READ_METHOD, + SKILL_LIMITS, + SKILL_MANIFEST_FILENAME, + SKILL_URI_SCHEME, SKILLS_EXTENSION_ID, SKILLS_GET_METHOD, SKILLS_LIST_METHOD, - skillsCapabilityOf + SKILLS_META_PREFIX } 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/src/ext/skills/pageSchemas.ts b/packages/client/src/ext/skills/pageSchemas.ts new file mode 100644 index 0000000000..4ba17e8fdb --- /dev/null +++ b/packages/client/src/ext/skills/pageSchemas.ts @@ -0,0 +1,17 @@ +import * as z from 'zod/v4'; + +/** + * `skills/list` and `skills/get` envelopes that leave entries unchecked, so a + * bad entry is reported on its own instead of failing the whole response. + * Defined with this package's own `zod`, so no schema crosses package copies. + */ +export const SkillsPageSchema = z.looseObject({ + skills: z.array(z.unknown()), + nextCursor: z.string().optional(), + ttlMs: z.number().int().nonnegative().optional(), + cacheScope: z.enum(['public', 'private']).optional() +}); + +export const GetSkillEnvelopeSchema = z.looseObject({ + skill: z.unknown() +}); diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts new file mode 100644 index 0000000000..28e7d3fb26 --- /dev/null +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -0,0 +1,164 @@ +import type { DirectoryResource, Skill, SkillCacheScope } from '@modelcontextprotocol/core/ext/skills'; +import { + ReadResourceDirectoryResultSchema, + RESOURCES_DIRECTORY_READ_METHOD, + SKILLS_EXTENSION_ID, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD, + skillsCapabilityOf +} from '@modelcontextprotocol/core/ext/skills'; +import { canonicalUri } from '@modelcontextprotocol/core-internal'; +import type { RequestOptions } from '@modelcontextprotocol/core-internal/public'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal/public'; +import type * as z from 'zod/v4'; + +import type { Client } from '../../client/client'; +import { clientSettings } from './clientInternals'; +import type { InvalidSkillEntry } from './entries'; +import { checkEntry, partitionEntries } from './entries'; +import { GetSkillEnvelopeSchema, SkillsPageSchema } from './pageSchemas'; + +export interface SkillsClientOptions { + /** Parses a `SKILL.md`'s frontmatter for verification instead of the built-in YAML parser. */ + parseFrontmatter?: (skillMd: string) => Record; + /** Throw when `list()` meets an invalid entry instead of returning it in `invalid`. Default `false`. */ + strict?: boolean; +} + +/** What `list()` returns: the valid entries, the refused ones, and the first page's caching fields. */ +export interface SkillListing { + skills: Skill[]; + invalid: InvalidSkillEntry[]; + /** Only when `list()` was given a cursor (one page); a full walk has none. */ + nextCursor?: string; + ttlMs?: number; + cacheScope?: SkillCacheScope; +} + +function unsupported(method: string): SdkError { + return new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support the ${SKILLS_EXTENSION_ID} extension (required for ${method})` + ); +} + +/** + * Host side of the MCP Skills extension (SEP-2640): lists, gets and reads a + * server's skills, and verifies every file against the server's entry. + * Approval, origin tagging and loading policy stay with the host. + * + * @example + * ```ts + * const skills = new SkillsClient(client); + * const { skills: entries } = await skills.list(); + * const manifest = await skills.read(entries[0]!); + * ``` + */ +export class SkillsClient { + readonly #client: Client; + readonly #options: SkillsClientOptions; + + constructor(client: Client, options: SkillsClientOptions = {}) { + this.#client = client; + this.#options = options; + } + + /** The server declared the extension (and `resources`, under `enforceStrictCapabilities`). `false` before connect. */ + get supported(): boolean { + const capabilities = this.#client.getServerCapabilities(); + if (skillsCapabilityOf(capabilities) === undefined) return false; + return !clientSettings(this.#client).enforceStrictCapabilities || capabilities?.resources !== undefined; + } + + /** The server serves `resources/directory/read`. */ + get directoryRead(): boolean { + return this.supported && skillsCapabilityOf(this.#client.getServerCapabilities())?.directoryRead === true; + } + + /** + * Lists the server's skills, walking every page unless given a cursor + * (bounded by the client's `listMaxPages`). Invalid entries, and every + * copy of a repeated URI, are returned in `invalid` (or thrown under + * `strict`). On a server without the extension: an empty listing. + */ + async list(params: { cursor?: string } = {}, options?: RequestOptions): Promise { + if (!this.supported) { + if (clientSettings(this.#client).enforceStrictCapabilities) throw unsupported(SKILLS_LIST_METHOD); + // eslint-disable-next-line no-console + console.debug(`SkillsClient.list() called but the server does not declare ${SKILLS_EXTENSION_ID} - returning an empty list`); + return { skills: [], invalid: [] }; + } + const pages = await this.#pages(SKILLS_LIST_METHOD, SkillsPageSchema, {}, params.cursor, options); + const { skills, invalid } = partitionEntries(pages.flatMap(page => page.skills)); + if (invalid.length > 0 && this.#options.strict) { + const detail = invalid.map(entry => `#${entry.index} ${entry.uri ?? ''}: ${entry.reason}`).join('\n'); + throw new SdkError(SdkErrorCode.InvalidResult, `skills/list returned invalid entries:\n${detail}`, { invalid }); + } + const first = pages[0]!; + const last = pages.at(-1)!; + return { + skills, + invalid, + ...(params.cursor !== undefined && last.nextCursor !== undefined && { nextCursor: last.nextCursor }), + ...(first.ttlMs !== undefined && { ttlMs: first.ttlMs }), + ...(first.cacheScope !== undefined && { cacheScope: first.cacheScope }) + }; + } + + /** One skill by its `SKILL.md` URI, listed or not. Throws if the server returns an invalid entry or another skill. */ + async get(uri: string, options?: RequestOptions): Promise { + if (!this.supported) throw unsupported(SKILLS_GET_METHOD); + const result = await this.#client.request({ method: SKILLS_GET_METHOD, params: { uri } }, GetSkillEnvelopeSchema, options); + const { skill, problems } = checkEntry(result.skill); + if (skill === undefined || problems.length > 0) { + throw new SdkError(SdkErrorCode.InvalidResult, `skills/get returned an invalid entry for ${uri}: ${problems.join('; ')}`); + } + if (canonicalUri(skill.uri) !== canonicalUri(uri)) { + throw new SdkError(SdkErrorCode.InvalidResult, `skills/get for ${uri} returned ${skill.uri}`); + } + return skill; + } + + /** The direct children of a skill directory, every page walked. Needs `directoryRead`. */ + async readDirectory(uri: string, options?: RequestOptions): Promise { + if (!this.directoryRead) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not declare directoryRead for ${SKILLS_EXTENSION_ID} (required for ${RESOURCES_DIRECTORY_READ_METHOD})` + ); + } + const pages = await this.#pages(RESOURCES_DIRECTORY_READ_METHOD, ReadResourceDirectoryResultSchema, { uri }, undefined, options); + return pages.flatMap(page => page.resources); + } + + /** One page (with `cursor`) or every page: stops at a repeated cursor, throws past `listMaxPages`. */ + async #pages( + method: string, + schema: z.ZodType, + params: Record, + cursor: string | undefined, + options: RequestOptions | undefined + ): Promise { + const fetchPage = (at: string | undefined): Promise => + this.#client.request({ method, params: at === undefined ? params : { ...params, cursor: at } }, schema, options); + if (cursor !== undefined) return [await fetchPage(cursor)]; + const { listMaxPages } = clientSettings(this.#client); + const pages = [await fetchPage(undefined)]; + const seen = new Set(); + let next = pages[0]!.nextCursor; + while (next !== undefined && !seen.has(next)) { + if (listMaxPages !== 0 && pages.length >= listMaxPages) { + throw new SdkError( + SdkErrorCode.ListPaginationExceeded, + `${method}: exceeded listMaxPages (${listMaxPages}); server pagination did not terminate`, + { method, listMaxPages } + ); + } + seen.add(next); + const page = await fetchPage(next); + pages.push(page); + next = page.nextCursor; + } + return pages; + } +} diff --git a/packages/client/test/ext/skills/clientInternals.test.ts b/packages/client/test/ext/skills/clientInternals.test.ts new file mode 100644 index 0000000000..63c27108ed --- /dev/null +++ b/packages/client/test/ext/skills/clientInternals.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../../src/client/client'; +import { clientSettings } from '../../../src/ext/skills/clientInternals'; + +const INFO = { name: 'c', version: '1.0.0' }; + +describe('clientSettings', () => { + it('reads the private Client settings it relies on, and fails if they are renamed', () => { + expect(clientSettings(new Client(INFO))).toEqual({ enforceStrictCapabilities: false, listMaxPages: 64 }); + expect(clientSettings(new Client(INFO, { enforceStrictCapabilities: true, listMaxPages: 3 }))).toEqual({ + enforceStrictCapabilities: true, + listMaxPages: 3 + }); + const privates = new Client(INFO) as unknown as Record; + expect(privates).toHaveProperty('_enforceStrictCapabilities'); + expect(privates).toHaveProperty('_listMaxPages'); + }); +}); diff --git a/packages/client/test/ext/skills/fixtures.ts b/packages/client/test/ext/skills/fixtures.ts new file mode 100644 index 0000000000..513c1b16af --- /dev/null +++ b/packages/client/test/ext/skills/fixtures.ts @@ -0,0 +1,46 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SKILLS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/skills'; +import type { ServerCapabilities } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, encodeSegment, encodeUtf8 } from '@modelcontextprotocol/core-internal'; + +export const CAPABILITIES: ServerCapabilities = { resources: {}, extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: true } } }; + +export const PDF_ROOT = 'skill://pdf-processing'; +export const PDF_SKILL_MD = + '---\nname: pdf-processing\ndescription: Extract text and fill forms in PDF files.\nmetadata:\n author: acme\n version: "1.0"\n---\n' + + '# PDF processing\n\nSee [the forms guide](references/FORMS.md).\n'; +export const FORMS_MD = '# Forms\n\nFill the fields in order.\n'; +export const PDF_FILES: Readonly> = { + 'SKILL.md': PDF_SKILL_MD, + 'references/FORMS.md': FORMS_MD, + 'templates/Purchase Order.md': '# PO\n' +}; + +/** The URI a conforming server publishes for `path` inside the skill at `root`: each segment percent-encoded. */ +export function uriOf(root: string, path: string): string { + return `${root}/${path + .split('/') + .map(segment => encodeSegment(segment)) + .join('/')}`; +} + +/** File contents keyed by published URI. */ +export function filesOf(root: string, files: Readonly>): Map { + return new Map(Object.entries(files).map(([path, content]) => [uriOf(root, path), content])); +} + +/** A valid entry for these files, as a conforming server publishes it. */ +export async function entryFor(root: string, files: Readonly>): Promise { + const inputs = Object.entries(files).map(([path, content]) => ({ + uri: uriOf(root, path), + bytes: typeof content === 'string' ? encodeUtf8(content) : content + })); + const { skill, problems } = await buildSkillEntry(`${root}/SKILL.md`, inputs); + if (skill === undefined || problems.length > 0) throw new Error(problems.join('; ')); + return skill; +} + +/** A valid skill called `name` with only a `SKILL.md`. */ +export function namedSkill(name: string): Promise { + return entryFor(`skill://${name}`, { 'SKILL.md': `---\nname: ${name}\ndescription: The ${name} skill.\n---\n` }); +} diff --git a/packages/client/test/ext/skills/scripted.ts b/packages/client/test/ext/skills/scripted.ts new file mode 100644 index 0000000000..26cdab6a33 --- /dev/null +++ b/packages/client/test/ext/skills/scripted.ts @@ -0,0 +1,120 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import type { JSONRPCRequest, ServerCapabilities } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; + +import type { ClientOptions } from '../../../src/client/client'; +import { Client } from '../../../src/client/client'; + +export const MODERN = '2026-07-28'; + +export type Handler = (params: Record) => Record | Promise>; + +export interface Call { + method: string; + params: Record; +} + +/** A JSON-RPC error for the scripted server to answer with. */ +export class RpcError extends Error { + constructor( + readonly code: number, + message: string + ) { + super(message); + } +} + +/** + * A client connected on 2026-07-28 to a scripted server: `server/discover` + * declares `capabilities`, every other request goes to `handlers` and is + * recorded in `calls`. Results carry `resultType: "complete"` as a conforming + * server's do (the typescript-sdk#2789 regression). + */ +export async function scriptedClient( + capabilities: ServerCapabilities, + handlers: Record, + options: ClientOptions = {} +): Promise<{ client: Client; calls: Call[] }> { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const calls: Call[] = []; + const answer = async (request: JSONRPCRequest): Promise> => { + if (request.method === 'server/discover') { + return { + supportedVersions: [MODERN], + capabilities, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted', version: '1.0.0' } } + }; + } + const params = (request.params ?? {}) as Record; + calls.push({ method: request.method, params }); + const handler = handlers[request.method]; + if (handler === undefined) throw new RpcError(-32_601, `Method not found: ${request.method}`); + return handler(params); + }; + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + if (request.id === undefined) return; + void answer(request).then( + result => serverTx.send({ jsonrpc: '2.0', id: request.id, result: { resultType: 'complete', ...result } }), + (error: unknown) => + serverTx.send({ + jsonrpc: '2.0', + id: request.id, + error: { + code: error instanceof RpcError ? error.code : -32_603, + message: error instanceof Error ? error.message : String(error) + } + }) + ); + }; + await serverTx.start(); + const client = new Client( + { name: 'skills-test-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, ...options } + ); + await client.connect(clientTx); + return { client, calls }; +} + +export interface SkillsHandlerOptions { + /** Entries per `skills/list` page. Default: all. */ + pageSize?: number; + /** `ttlMs` on `resources/read` results, so the client caches them. Default: none. */ + readTtlMs?: number; +} + +/** A conforming skills server over `skills` and the file contents in `files` (keyed by URI as published). */ +export function skillsHandlers( + skills: readonly Skill[], + files: ReadonlyMap, + options: SkillsHandlerOptions = {} +): Record { + const pageSize = options.pageSize ?? Number.POSITIVE_INFINITY; + return { + 'skills/list': params => { + const start = params['cursor'] === undefined ? 0 : Number(params['cursor']); + const end = start + pageSize; + return { + skills: skills.slice(start, end), + ttlMs: 60_000, + cacheScope: 'public', + ...(end < skills.length && { nextCursor: String(end) }) + }; + }, + 'skills/get': params => { + const skill = skills.find(candidate => candidate.uri === params['uri']); + if (skill === undefined) throw new RpcError(-32_602, `No skill is served at ${String(params['uri'])}`); + return { skill, ttlMs: 0, cacheScope: 'private' }; + }, + 'resources/read': params => { + const uri = String(params['uri']); + const content = files.get(uri); + if (content === undefined) throw new RpcError(-32_602, `Resource not found: ${uri}`); + const body = + typeof content === 'string' + ? { uri, mimeType: 'text/markdown', text: content } + : { uri, mimeType: 'application/octet-stream', blob: Buffer.from(content).toString('base64') }; + return { contents: [body], ...(options.readTtlMs !== undefined && { ttlMs: options.readTtlMs, cacheScope: 'private' }) }; + } + }; +} diff --git a/packages/client/test/ext/skills/skills.test.ts b/packages/client/test/ext/skills/skills.test.ts deleted file mode 100644 index f8d745b3f6..0000000000 --- a/packages/client/test/ext/skills/skills.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * 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/test/ext/skills/skillsClient.test.ts b/packages/client/test/ext/skills/skillsClient.test.ts new file mode 100644 index 0000000000..ad5834025c --- /dev/null +++ b/packages/client/test/ext/skills/skillsClient.test.ts @@ -0,0 +1,180 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { SKILLS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/skills'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../../src/client/client'; +import { SkillsClient } from '../../../src/ext/skills/index'; +import { CAPABILITIES, namedSkill, PDF_ROOT } from './fixtures'; +import type { Call, Handler } from './scripted'; +import { RpcError, scriptedClient, skillsHandlers } from './scripted'; + +const NO_DIRECTORIES = { resources: {}, extensions: { [SKILLS_EXTENSION_ID]: {} } }; +const DIGEST = `sha256:${'0'.repeat(64)}`; + +const methodCalls = (calls: Call[], method: string): Call[] => calls.filter(call => call.method === method); + +async function threeSkills(): Promise { + return Promise.all(['alpha', 'beta', 'gamma'].map(name => namedSkill(name))); +} + +describe('SkillsClient capability', () => { + it('is unsupported before connect', () => { + const skills = new SkillsClient(new Client({ name: 'c', version: '1.0.0' })); + expect(skills.supported).toBe(false); + expect(skills.directoryRead).toBe(false); + }); + + it('reads the declared extension and directoryRead', async () => { + const full = await scriptedClient(CAPABILITIES, {}); + const withDirectories = new SkillsClient(full.client); + expect([withDirectories.supported, withDirectories.directoryRead]).toEqual([true, true]); + const partial = await scriptedClient(NO_DIRECTORIES, {}); + const withoutDirectories = new SkillsClient(partial.client); + expect([withoutDirectories.supported, withoutDirectories.directoryRead]).toEqual([true, false]); + }); + + it('also requires resources under enforceStrictCapabilities', async () => { + const bare = { extensions: { [SKILLS_EXTENSION_ID]: {} } }; + const lenient = await scriptedClient(bare, {}); + expect(new SkillsClient(lenient.client).supported).toBe(true); + const strict = await scriptedClient(bare, {}, { enforceStrictCapabilities: true }); + expect(new SkillsClient(strict.client).supported).toBe(false); + }); +}); + +describe('SkillsClient.list', () => { + it('walks every page and keeps the first page caching fields', async () => { + const entries = await threeSkills(); + const { client, calls } = await scriptedClient(CAPABILITIES, skillsHandlers(entries, new Map(), { pageSize: 1 })); + const listing = await new SkillsClient(client).list(); + expect(listing).toEqual({ skills: entries, invalid: [], ttlMs: 60_000, cacheScope: 'public' }); + expect(listing).not.toHaveProperty('resultType'); + expect(methodCalls(calls, 'skills/list')).toHaveLength(3); + }); + + it('returns one page when given a cursor', async () => { + const entries = await threeSkills(); + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers(entries, new Map(), { pageSize: 1 })); + expect(await new SkillsClient(client).list({ cursor: '1' })).toMatchObject({ skills: [entries[1]], nextCursor: '2' }); + }); + + it('moves invalid entries, and every copy of a repeated URI, into invalid', async () => { + const [alpha, beta, gamma] = await threeSkills(); + const misnamed = { ...beta!, frontmatter: { ...beta!.frontmatter, name: 'other' } }; + const escaping = { + ...gamma!, + resources: [...(gamma!.resources as object[]), { uri: 'skill://gamma/..%2Fevil/x.md', digest: DIGEST, size: 1 }] + }; + const handlers: Record = { 'skills/list': () => ({ skills: [alpha, misnamed, escaping, { uri: 42 }, alpha] }) }; + const { client } = await scriptedClient(CAPABILITIES, handlers); + const { skills, invalid } = await new SkillsClient(client).list(); + expect(skills).toEqual([]); + expect(invalid).toEqual([ + { index: 0, uri: alpha!.uri, reason: expect.stringMatching(/more than once/) }, + { index: 1, uri: beta!.uri, reason: expect.stringMatching(/last path segment/) }, + { index: 2, uri: gamma!.uri, reason: expect.stringMatching(/outside the skill directory/) }, + { index: 3, reason: expect.any(String) }, + { index: 4, uri: alpha!.uri, reason: expect.stringMatching(/more than once/) } + ]); + }); + + it('throws on any invalid entry when strict', async () => { + const [alpha] = await threeSkills(); + const { client } = await scriptedClient(CAPABILITIES, { 'skills/list': () => ({ skills: [alpha, alpha] }) }); + await expect(new SkillsClient(client, { strict: true }).list()).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult }); + }); + + it('throws on a malformed response', async () => { + const { client } = await scriptedClient(CAPABILITIES, { 'skills/list': () => ({ skills: 'nope' }) }); + await expect(new SkillsClient(client).list()).rejects.toThrow(/skills\/list/); + }); + + it('returns an empty listing from a server without the extension, without asking it', async () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined); + const { client, calls } = await scriptedClient({ resources: {} }, {}); + expect(await new SkillsClient(client).list()).toEqual({ skills: [], invalid: [] }); + expect(calls).toEqual([]); + debug.mockRestore(); + }); + + it('throws CapabilityNotSupported for such a server under enforceStrictCapabilities', async () => { + const { client } = await scriptedClient({ resources: {} }, {}, { enforceStrictCapabilities: true }); + await expect(new SkillsClient(client).list()).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); + }); + + it('stops when a server repeats a cursor', async () => { + const [alpha] = await threeSkills(); + const { client, calls } = await scriptedClient(CAPABILITIES, { 'skills/list': () => ({ skills: [alpha], nextCursor: 'same' }) }); + const listing = await new SkillsClient(client).list(); + expect(methodCalls(calls, 'skills/list')).toHaveLength(2); + expect(listing.skills).toEqual([]); + expect(listing.invalid).toHaveLength(2); + }); + + it('gives up after listMaxPages on endless pagination', async () => { + const endless: Handler = params => ({ skills: [], nextCursor: String(Number(params['cursor'] ?? 0) + 1) }); + const { client, calls } = await scriptedClient(CAPABILITIES, { 'skills/list': endless }, { listMaxPages: 5 }); + const error = await new SkillsClient(client).list().catch((error: unknown) => error); + expect(error).toBeInstanceOf(SdkError); + expect(error).toMatchObject({ code: SdkErrorCode.ListPaginationExceeded }); + expect(methodCalls(calls, 'skills/list')).toHaveLength(5); + }); +}); + +describe('SkillsClient.get', () => { + it('returns the entry', async () => { + const [alpha] = await threeSkills(); + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers([alpha!], new Map())); + expect(await new SkillsClient(client).get(alpha!.uri)).toEqual(alpha); + }); + + it('passes server errors through', async () => { + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers([], new Map())); + await expect(new SkillsClient(client).get('skill://nope/SKILL.md')).rejects.toMatchObject({ code: -32_602 }); + }); + + it('throws CapabilityNotSupported on a server without the extension', async () => { + const { client } = await scriptedClient({ resources: {} }, {}); + await expect(new SkillsClient(client).get('skill://alpha/SKILL.md')).rejects.toMatchObject({ + code: SdkErrorCode.CapabilityNotSupported + }); + }); + + it('throws when the server returns another skill or an invalid entry', async () => { + const [alpha, beta] = await threeSkills(); + const other = await scriptedClient(CAPABILITIES, { 'skills/get': () => ({ skill: beta }) }); + await expect(new SkillsClient(other.client).get(alpha!.uri)).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult }); + const invalid = { ...alpha!, frontmatter: { ...alpha!.frontmatter, name: 'Not Valid' } }; + const broken = await scriptedClient(CAPABILITIES, { 'skills/get': () => ({ skill: invalid }) }); + await expect(new SkillsClient(broken.client).get(alpha!.uri)).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult }); + }); +}); + +describe('SkillsClient.readDirectory', () => { + const references = { uri: `${PDF_ROOT}/references`, name: 'references', mimeType: 'inode/directory' }; + const manifest = { uri: `${PDF_ROOT}/SKILL.md`, name: 'SKILL.md', mimeType: 'text/markdown', size: 10 }; + + it('walks every page', async () => { + const pages: Handler = params => + params['cursor'] === undefined + ? { resources: [manifest], nextCursor: 'p2', ttlMs: 0, cacheScope: 'private' } + : { resources: [references], ttlMs: 0, cacheScope: 'private' }; + const { client, calls } = await scriptedClient(CAPABILITIES, { 'resources/directory/read': pages }); + expect(await new SkillsClient(client).readDirectory(PDF_ROOT)).toEqual([manifest, references]); + expect(methodCalls(calls, 'resources/directory/read').map(call => call.params['uri'])).toEqual([PDF_ROOT, PDF_ROOT]); + }); + + it('throws CapabilityNotSupported without directoryRead', async () => { + const { client } = await scriptedClient(NO_DIRECTORIES, {}); + await expect(new SkillsClient(client).readDirectory(PDF_ROOT)).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); + }); + + it('passes server errors through', async () => { + const missing: Handler = () => { + throw new RpcError(-32_602, 'not a directory'); + }; + const { client } = await scriptedClient(CAPABILITIES, { 'resources/directory/read': missing }); + await expect(new SkillsClient(client).readDirectory(`${PDF_ROOT}/SKILL.md`)).rejects.toMatchObject({ code: -32_602 }); + }); +}); From 02c847950c81d5f4f5e34a6cfa1ef73af7b763f0 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 06:45:06 +0300 Subject: [PATCH 18/45] feat(client): SkillsClient with verified reads new SkillsClient(client) lists (every page, invalid and duplicate entries reported in invalid, or thrown under strict), gets and reads skills. read(skill, ref) resolves ref against the skill root, refuses unlisted files before any request, checks size, digest and SKILL.md frontmatter, and fetches once more with cacheMode 'refresh' when a cached copy fails. Replaces listSkills/getSkill from #2818. Co-authored-by: Matt Carey --- packages/client/src/ext/skills/errors.ts | 34 ++++ packages/client/src/ext/skills/index.ts | 4 +- .../client/src/ext/skills/skillsClient.ts | 135 +++++++++++++- packages/client/src/ext/skills/verify.ts | 27 +++ packages/client/test/ext/skills/read.test.ts | 171 ++++++++++++++++++ packages/client/test/ext/skills/scripted.ts | 5 +- .../client/test/ext/skills/verify.test.ts | 39 ++++ 7 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 packages/client/src/ext/skills/errors.ts create mode 100644 packages/client/src/ext/skills/verify.ts create mode 100644 packages/client/test/ext/skills/read.test.ts create mode 100644 packages/client/test/ext/skills/verify.test.ts diff --git a/packages/client/src/ext/skills/errors.ts b/packages/client/src/ext/skills/errors.ts new file mode 100644 index 0000000000..ea2eedf4d8 --- /dev/null +++ b/packages/client/src/ext/skills/errors.ts @@ -0,0 +1,34 @@ +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal/public'; + +/** Why a skill file failed verification. */ +export type SkillVerificationReason = 'unlisted' | 'size' | 'digest' | 'frontmatter'; + +/** + * A skill file failed verification against its skill entry: not listed, + * wrong size, wrong digest, or (for `SKILL.md`) frontmatter that differs from + * the entry. Hosts must not load the file; for `SKILL.md`, not the skill. + */ +export class SkillVerificationError extends SdkError { + static { + Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SkillVerificationError' }); + } + + readonly reason: SkillVerificationReason; + readonly uri: string; + /** For `frontmatter`: one line per differing field. */ + readonly differences: readonly string[]; + + constructor( + reason: SkillVerificationReason, + uri: string, + message: string, + differences: readonly string[] = [], + options?: ErrorOptions + ) { + super(SdkErrorCode.InvalidResult, message, { reason, uri, differences }, options); + this.name = 'SkillVerificationError'; + this.reason = reason; + this.uri = uri; + this.differences = differences; + } +} diff --git a/packages/client/src/ext/skills/index.ts b/packages/client/src/ext/skills/index.ts index 74ead1b823..aef9408d45 100644 --- a/packages/client/src/ext/skills/index.ts +++ b/packages/client/src/ext/skills/index.ts @@ -5,7 +5,9 @@ // (modelcontextprotocol/ext-skills#71), typescript-sdk#2818 and #2972. export type { InvalidSkillEntry } from './entries'; -export type { SkillListing, SkillsClientOptions } from './skillsClient'; +export type { SkillVerificationReason } from './errors'; +export { SkillVerificationError } from './errors'; +export type { SkillFile, SkillListing, SkillsClientOptions } from './skillsClient'; export { SkillsClient } from './skillsClient'; export type { DirectoryResource, diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index 28e7d3fb26..7268b2044d 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -1,4 +1,4 @@ -import type { DirectoryResource, Skill, SkillCacheScope } from '@modelcontextprotocol/core/ext/skills'; +import type { DirectoryResource, Skill, SkillCacheScope, SkillResourceEntry } from '@modelcontextprotocol/core/ext/skills'; import { ReadResourceDirectoryResultSchema, RESOURCES_DIRECTORY_READ_METHOD, @@ -7,16 +7,29 @@ import { SKILLS_LIST_METHOD, skillsCapabilityOf } from '@modelcontextprotocol/core/ext/skills'; -import { canonicalUri } from '@modelcontextprotocol/core-internal'; +import { + canonicalUri, + decodeBase64, + decodeUtf8Lossless, + encodeUtf8, + isWithin, + parseSkillMarkdown, + resolveSkillRef, + sha256Digest, + skillRootUri, + splitFrontmatter +} from '@modelcontextprotocol/core-internal'; import type { RequestOptions } from '@modelcontextprotocol/core-internal/public'; import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal/public'; import type * as z from 'zod/v4'; -import type { Client } from '../../client/client'; +import type { CacheableRequestOptions, Client } from '../../client/client'; import { clientSettings } from './clientInternals'; import type { InvalidSkillEntry } from './entries'; import { checkEntry, partitionEntries } from './entries'; +import { SkillVerificationError } from './errors'; import { GetSkillEnvelopeSchema, SkillsPageSchema } from './pageSchemas'; +import { frontmatterDifferences } from './verify'; export interface SkillsClientOptions { /** Parses a `SKILL.md`'s frontmatter for verification instead of the built-in YAML parser. */ @@ -35,6 +48,32 @@ export interface SkillListing { cacheScope?: SkillCacheScope; } +/** A skill file as read and checked by {@linkcode SkillsClient.read}. */ +export interface SkillFile { + uri: string; + mimeType?: string; + /** Exactly the bytes the entry's digest covers. */ + bytes: Uint8Array; + /** When the server sent text (always, for the skill's own `SKILL.md`). */ + text?: string; + /** Size and digest were checked. `false` only for `"dynamic"` skills. */ + verified: boolean; + /** The skill's own `SKILL.md` only: its parsed frontmatter, equal to the entry's. */ + frontmatter?: Record; + /** The skill's own `SKILL.md` only: the markdown after the frontmatter. */ + body?: string; +} + +function decodeBlob(uri: string, blob: string): Uint8Array { + try { + return decodeBase64(blob); + } catch (error) { + throw new SdkError(SdkErrorCode.InvalidResult, `resources/read returned an invalid base64 blob for ${uri}`, undefined, { + cause: error + }); + } +} + function unsupported(method: string): SdkError { return new SdkError( SdkErrorCode.CapabilityNotSupported, @@ -119,6 +158,96 @@ export class SkillsClient { return skill; } + /** + * Reads one file of `skill` and verifies it. `ref` is a path relative to + * the skill's root (`references/FORMS.md`) or an absolute URI; the + * default is the skill's own `SKILL.md`. The file must be listed in the + * entry (checked before any request), and its size and digest must match; + * for the skill's own `SKILL.md` the frontmatter must also equal the + * entry's. A failed check on what may be a cached copy is fetched once + * more with `cacheMode: 'refresh'`. `"dynamic"` skills skip the listing, + * size and digest checks (`verified: false`) but not the frontmatter. + * Pass entries from {@linkcode SkillsClient.list} or {@linkcode SkillsClient.get}. + * + * @throws {SkillVerificationError} when a check fails. + */ + async read(skill: Skill, ref?: string, options?: CacheableRequestOptions): Promise { + if (!this.supported) throw unsupported('resources/read'); + const root = skillRootUri(skill.uri); + if (root === undefined) throw new TypeError(`${skill.uri} is not a SKILL.md URI`); + const uri = resolveSkillRef(skill.uri, ref); + const canonical = canonicalUri(uri); + const listed = + skill.resources === 'dynamic' ? undefined : skill.resources.find(resource => canonicalUri(resource.uri) === canonical); + const inSkill = skill.resources === 'dynamic' ? canonical === canonicalUri(skill.uri) || isWithin(root, uri) : listed !== undefined; + if (!inSkill) throw new SkillVerificationError('unlisted', uri, `${uri} is not a file of the skill ${skill.uri}`); + const target = listed?.uri ?? uri; + try { + return await this.#readVerified(skill, target, listed, options); + } catch (error) { + const mayBeCached = options?.cacheMode === undefined || options.cacheMode === 'use'; + if (!(error instanceof SkillVerificationError) || !mayBeCached) throw error; + // The failing copy may have come from the response cache; the spec requires fetching it again. + return this.#readVerified(skill, target, listed, { ...options, cacheMode: 'refresh' }); + } + } + + async #readVerified( + skill: Skill, + uri: string, + listed: SkillResourceEntry | undefined, + options: CacheableRequestOptions | undefined + ): Promise { + const result = await this.#client.readResource({ uri }, options); + const content = result.contents.find(item => canonicalUri(item.uri) === canonicalUri(uri)) ?? result.contents[0]; + if (content === undefined) throw new SdkError(SdkErrorCode.InvalidResult, `resources/read returned no contents for ${uri}`); + const bytes = 'text' in content ? encodeUtf8(content.text) : decodeBlob(uri, content.blob); + if (listed !== undefined) { + if (bytes.byteLength !== listed.size) { + throw new SkillVerificationError('size', uri, `${uri} is ${bytes.byteLength} bytes; the skill entry lists ${listed.size}`); + } + const digest = await sha256Digest(bytes); + if (digest !== listed.digest) { + throw new SkillVerificationError('digest', uri, `${uri} has digest ${digest}; the skill entry lists ${listed.digest}`); + } + } + const file: SkillFile = { + uri, + ...(content.mimeType !== undefined && { mimeType: content.mimeType }), + bytes, + ...('text' in content && { text: content.text }), + verified: listed !== undefined + }; + if (canonicalUri(uri) !== canonicalUri(skill.uri)) return file; + const markdown = 'text' in content ? content.text : decodeUtf8Lossless(bytes); + if (markdown === undefined) throw new SkillVerificationError('frontmatter', uri, `${uri} is not valid UTF-8`); + const { frontmatter, body } = this.#parse(uri, markdown); + const differences = frontmatterDifferences(skill.frontmatter, frontmatter); + if (differences.length > 0) { + throw new SkillVerificationError( + 'frontmatter', + uri, + `the frontmatter of ${uri} differs from the skill entry: ${differences.join('; ')}`, + differences + ); + } + return { ...file, text: markdown, frontmatter, body }; + } + + #parse(uri: string, markdown: string): { frontmatter: Record; body: string } { + try { + const parse = this.#options.parseFrontmatter; + return parse === undefined + ? parseSkillMarkdown(markdown) + : { frontmatter: parse(markdown), body: splitFrontmatter(markdown).body }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new SkillVerificationError('frontmatter', uri, `the frontmatter of ${uri} cannot be read: ${reason}`, [], { + cause: error + }); + } + } + /** The direct children of a skill directory, every page walked. Needs `directoryRead`. */ async readDirectory(uri: string, options?: RequestOptions): Promise { if (!this.directoryRead) { diff --git a/packages/client/src/ext/skills/verify.ts b/packages/client/src/ext/skills/verify.ts new file mode 100644 index 0000000000..40d8289123 --- /dev/null +++ b/packages/client/src/ext/skills/verify.ts @@ -0,0 +1,27 @@ +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const show = (value: unknown): string => (value === undefined ? 'nothing' : JSON.stringify(value)); + +const fieldOf = (path: string, key: string): string => (path === '' ? key : `${path}.${key}`); + +/** `object[key]` when `key` is its own: a missing `constructor` or `__proto__` must read as nothing, not Object.prototype's. */ +const ownValue = (object: Record, key: string): unknown => (Object.hasOwn(object, key) ? object[key] : undefined); + +/** + * Field-level differences between a skill entry's frontmatter and the + * frontmatter parsed from its `SKILL.md`: every key both ways, objects in any + * key order, arrays in order, numbers by value, no type coercion. + */ +export function frontmatterDifferences(entry: unknown, parsed: unknown, path = ''): string[] { + const label = path === '' ? '(frontmatter)' : path; + if (Array.isArray(entry) && Array.isArray(parsed)) { + if (entry.length !== parsed.length) return [`${label}: the entry has ${entry.length} items, SKILL.md has ${parsed.length}`]; + return entry.flatMap((item, index) => frontmatterDifferences(item, parsed[index], `${path}[${index}]`)); + } + if (isPlainObject(entry) && isPlainObject(parsed)) { + const keys = [...new Set([...Object.keys(entry), ...Object.keys(parsed)])].toSorted(); + return keys.flatMap(key => frontmatterDifferences(ownValue(entry, key), ownValue(parsed, key), fieldOf(path, key))); + } + return entry === parsed ? [] : [`${label}: the entry has ${show(entry)}, SKILL.md has ${show(parsed)}`]; +} diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts new file mode 100644 index 0000000000..946dc182d9 --- /dev/null +++ b/packages/client/test/ext/skills/read.test.ts @@ -0,0 +1,171 @@ +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { encodeUtf8, SdkErrorCode, sha256Digest } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SkillsClientOptions } from '../../../src/ext/skills/index'; +import { SkillsClient, SkillVerificationError } from '../../../src/ext/skills/index'; +import { CAPABILITIES, entryFor, filesOf, FORMS_MD, PDF_FILES, PDF_ROOT, PDF_SKILL_MD, uriOf } from './fixtures'; +import type { Call } from './scripted'; +import { scriptedClient, skillsHandlers } from './scripted'; + +const MANIFEST = `${PDF_ROOT}/SKILL.md`; +const FORMS = `${PDF_ROOT}/references/FORMS.md`; + +interface Setup { + files?: Readonly>; + options?: SkillsClientOptions; + readTtlMs?: number; +} + +async function connected({ files = PDF_FILES, options = {}, readTtlMs }: Setup = {}) { + const skill = await entryFor(PDF_ROOT, files); + const served = filesOf(PDF_ROOT, files); + const { client, calls } = await scriptedClient(CAPABILITIES, skillsHandlers([skill], served, { readTtlMs })); + return { skill, served, calls, skills: new SkillsClient(client, options) }; +} + +const reads = (calls: Call[]): unknown[] => calls.filter(call => call.method === 'resources/read').map(call => call.params['uri']); + +describe('SkillsClient.read', () => { + it("reads and verifies the skill's own SKILL.md", async () => { + const { skills, skill } = await connected(); + const file = await skills.read(skill); + expect(file).toMatchObject({ + uri: MANIFEST, + text: PDF_SKILL_MD, + verified: true, + frontmatter: { name: 'pdf-processing', metadata: { author: 'acme', version: '1.0' } }, + body: expect.stringContaining('# PDF processing') + }); + expect(file.bytes).toEqual(encodeUtf8(PDF_SKILL_MD)); + }); + + it('resolves a relative path or an absolute URI against the skill root', async () => { + const { skills, skill } = await connected(); + for (const ref of ['references/FORMS.md', './references/FORMS.md', 'references/../references/FORMS.md', FORMS]) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: FORMS, text: FORMS_MD, verified: true }); + } + }); + + it('resolves file names with a space to the URI the server published', async () => { + const { skills, skill } = await connected(); + const published = uriOf(PDF_ROOT, 'templates/Purchase Order.md'); + expect(published).toBe('skill://pdf-processing/templates/Purchase%20Order.md'); + expect(await skills.read(skill, 'templates/Purchase Order.md')).toMatchObject({ uri: published, text: '# PO\n', verified: true }); + }); + + it('refuses unlisted files before sending any request', async () => { + const { skills, skill, calls } = await connected(); + for (const ref of ['references/MISSING.md', '../../etc/passwd', 'skill://other-skill/SKILL.md', 'https://example.com/x']) { + await expect(skills.read(skill, ref)).rejects.toMatchObject({ reason: 'unlisted' }); + } + expect(reads(calls)).toEqual([]); + }); + + it('rejects a file whose size differs from the entry', async () => { + const { skills, skill, served } = await connected(); + served.set(FORMS, '# Forms, but longer\n'); + await expect(skills.read(skill, 'references/FORMS.md')).rejects.toMatchObject({ reason: 'size', uri: FORMS }); + }); + + it('rejects a file whose digest differs from the entry', async () => { + const { skills, skill, served } = await connected(); + served.set(FORMS, FORMS_MD.toUpperCase()); + const error = await skills.read(skill, 'references/FORMS.md').catch((error: unknown) => error); + expect(error).toBeInstanceOf(SkillVerificationError); + expect(error).toMatchObject({ reason: 'digest', uri: FORMS, code: SdkErrorCode.InvalidResult }); + }); + + it('rejects a SKILL.md whose frontmatter differs from the entry, naming the field', async () => { + const { skills, skill } = await connected(); + const tampered: Skill = { ...skill, frontmatter: { ...skill.frontmatter, metadata: { author: 'someone-else', version: '1.0' } } }; + await expect(skills.read(tampered)).rejects.toMatchObject({ + reason: 'frontmatter', + differences: [expect.stringContaining('metadata.author')] + }); + }); + + it('fails closed on YAML outside the supported subset', async () => { + const markdown = '---\nname: &n pdf-processing\ndescription: d\n---\n'; + const bytes = encodeUtf8(markdown); + const skill: Skill = { + uri: MANIFEST, + frontmatter: { name: 'pdf-processing', description: 'd' }, + resources: [{ uri: MANIFEST, digest: await sha256Digest(bytes), size: bytes.byteLength }] + }; + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers([skill], new Map([[MANIFEST, markdown]]))); + await expect(new SkillsClient(client).read(skill)).rejects.toMatchObject({ reason: 'frontmatter' }); + }); + + it('uses parseFrontmatter when given', async () => { + const parseFrontmatter = vi.fn(() => ({ + name: 'pdf-processing', + description: 'Extract text and fill forms in PDF files.', + metadata: { author: 'acme', version: '1.0' } + })); + const { skills, skill } = await connected({ options: { parseFrontmatter } }); + expect(await skills.read(skill)).toMatchObject({ verified: true, body: expect.stringContaining('# PDF processing') }); + expect(parseFrontmatter).toHaveBeenCalledWith(PDF_SKILL_MD); + }); + + it('verifies a Windows-authored SKILL.md (BOM and CRLF)', async () => { + const windows = { 'SKILL.md': '---\r\nname: pdf-processing\r\ndescription: Extract text.\r\n---\r\n# PDF\r\n' }; + const { skills, skill } = await connected({ files: windows }); + expect(await skills.read(skill)).toMatchObject({ + verified: true, + frontmatter: { name: 'pdf-processing', description: 'Extract text.' } + }); + }); + + it('decodes and verifies binary files', async () => { + const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const { skills, skill } = await connected({ files: { ...PDF_FILES, 'assets/logo.png': png } }); + const file = await skills.read(skill, 'assets/logo.png'); + expect(file).toMatchObject({ verified: true, mimeType: 'application/octet-stream' }); + expect(file.bytes).toEqual(png); + expect(file).not.toHaveProperty('text'); + }); + + it("treats a nested skill's SKILL.md as an ordinary file of the enclosing skill", async () => { + const { skills, skill } = await connected({ + files: { ...PDF_FILES, 'forms/SKILL.md': '---\nname: forms\ndescription: Nested.\n---\n' } + }); + const file = await skills.read(skill, 'forms/SKILL.md'); + expect(file).toMatchObject({ verified: true }); + expect(file).not.toHaveProperty('frontmatter'); + }); + + it('reads dynamic skills unverified but still compares SKILL.md frontmatter', async () => { + const { skills, skill } = await connected(); + const dynamic: Skill = { ...skill, resources: 'dynamic' }; + expect(await skills.read(dynamic, 'references/FORMS.md')).toMatchObject({ verified: false, text: FORMS_MD }); + expect(await skills.read(dynamic)).toMatchObject({ verified: false, frontmatter: { name: 'pdf-processing' } }); + const tampered = { ...dynamic, frontmatter: { ...dynamic.frontmatter, description: 'other' } }; + await expect(skills.read(tampered)).rejects.toMatchObject({ reason: 'frontmatter' }); + await expect(skills.read(dynamic, 'skill://other-skill/SKILL.md')).rejects.toMatchObject({ reason: 'unlisted' }); + }); + + it('fetches again once when a cached copy fails verification', async () => { + const { skills, skill, served, calls } = await connected({ readTtlMs: 60_000 }); + await skills.read(skill, 'references/FORMS.md'); + served.set(FORMS, '# Forms v2\n'); + const updated = await entryFor(PDF_ROOT, { ...PDF_FILES, 'references/FORMS.md': '# Forms v2\n' }); + expect(await skills.read(updated, 'references/FORMS.md')).toMatchObject({ text: '# Forms v2\n', verified: true }); + expect(reads(calls)).toEqual([FORMS, FORMS]); + }); + + it('gives up after that one extra fetch, and makes none under cacheMode bypass', async () => { + const { skills, skill, served, calls } = await connected(); + served.set(FORMS, FORMS_MD.toUpperCase()); + await expect(skills.read(skill, 'references/FORMS.md')).rejects.toMatchObject({ reason: 'digest' }); + expect(reads(calls)).toEqual([FORMS, FORMS]); + await expect(skills.read(skill, 'references/FORMS.md', { cacheMode: 'bypass' })).rejects.toMatchObject({ reason: 'digest' }); + expect(reads(calls)).toHaveLength(3); + }); + + it('throws CapabilityNotSupported on a server without the extension', async () => { + const skill = await entryFor(PDF_ROOT, PDF_FILES); + const { client } = await scriptedClient({ resources: {} }, {}); + await expect(new SkillsClient(client).read(skill)).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); + }); +}); diff --git a/packages/client/test/ext/skills/scripted.ts b/packages/client/test/ext/skills/scripted.ts index 26cdab6a33..9cbab374db 100644 --- a/packages/client/test/ext/skills/scripted.ts +++ b/packages/client/test/ext/skills/scripted.ts @@ -79,7 +79,7 @@ export async function scriptedClient( export interface SkillsHandlerOptions { /** Entries per `skills/list` page. Default: all. */ pageSize?: number; - /** `ttlMs` on `resources/read` results, so the client caches them. Default: none. */ + /** `ttlMs` on `resources/read` results; above 0 the client caches them. Default: 0 (not cached). */ readTtlMs?: number; } @@ -114,7 +114,8 @@ export function skillsHandlers( typeof content === 'string' ? { uri, mimeType: 'text/markdown', text: content } : { uri, mimeType: 'application/octet-stream', blob: Buffer.from(content).toString('base64') }; - return { contents: [body], ...(options.readTtlMs !== undefined && { ttlMs: options.readTtlMs, cacheScope: 'private' }) }; + // 2026-07-28 requires both caching fields on every cacheable result. + return { contents: [body], ttlMs: options.readTtlMs ?? 0, cacheScope: 'private' }; } }; } diff --git a/packages/client/test/ext/skills/verify.test.ts b/packages/client/test/ext/skills/verify.test.ts new file mode 100644 index 0000000000..b70a737fc2 --- /dev/null +++ b/packages/client/test/ext/skills/verify.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { frontmatterDifferences } from '../../../src/ext/skills/verify'; + +describe('frontmatterDifferences', () => { + it('accepts equal values, objects in any key order', () => { + expect(frontmatterDifferences({ name: 'a', metadata: { x: '1', y: '2' } }, { metadata: { y: '2', x: '1' }, name: 'a' })).toEqual( + [] + ); + }); + + it('names every differing field, both ways', () => { + expect(frontmatterDifferences({ name: 'a', extra: 'e' }, { name: 'b', other: 'o' })).toEqual([ + 'extra: the entry has "e", SKILL.md has nothing', + 'name: the entry has "a", SKILL.md has "b"', + 'other: the entry has nothing, SKILL.md has "o"' + ]); + }); + + it('compares arrays in order and never coerces types', () => { + expect(frontmatterDifferences({ tags: ['a', 'b'] }, { tags: ['b', 'a'] })).toHaveLength(2); + expect(frontmatterDifferences({ tags: ['a'] }, { tags: ['a', 'b'] })).toEqual(['tags: the entry has 1 items, SKILL.md has 2']); + expect(frontmatterDifferences({ version: 1 }, { version: '1' })).toEqual(['version: the entry has 1, SKILL.md has "1"']); + expect(frontmatterDifferences({ value: null }, { value: false })).toHaveLength(1); + expect(frontmatterDifferences({ n: 1 }, { n: 1 })).toEqual([]); + }); + + it('reports nested paths', () => { + expect(frontmatterDifferences({ metadata: { author: 'a' } }, { metadata: { author: 'b' } })).toEqual([ + 'metadata.author: the entry has "a", SKILL.md has "b"' + ]); + }); + + it('reads own keys only, never what Object.prototype supplies', () => { + expect(frontmatterDifferences({}, { constructor: 'x' })).toEqual(['constructor: the entry has nothing, SKILL.md has "x"']); + // JSON.parse makes "__proto__" an own key; a lookup through the prototype would see Object.prototype, an empty object. + expect(frontmatterDifferences(JSON.parse('{"__proto__":{}}'), {})).toEqual(['__proto__: the entry has {}, SKILL.md has nothing']); + }); +}); From ceafeb9cd10b596f2957f134010042c5afb9720f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 07:06:31 +0300 Subject: [PATCH 19/45] fix(skills): publish skill URIs as URL serialises them and match reads by file key encodeSegment now emits each path segment the way WHATWG URL serialises it (always encoding % / ? # and \), so a host that resolves "references/Q&A.md" gets the published URI. SkillsClient.read matches refs to the listing by a normalised file key (fragment dropped, segments re-encoded) so servers that encode differently still verify, refuses unparsable refs as unlisted, and decides "own SKILL.md" by the same key. --- .../client/src/ext/skills/skillsClient.ts | 72 ++++++++++----- packages/client/test/ext/skills/read.test.ts | 89 ++++++++++++++++++- .../core-internal/src/ext/skills/index.ts | 1 + packages/core-internal/src/ext/skills/uri.ts | 41 ++++++++- .../core-internal/test/ext/skills/uri.test.ts | 46 ++++++++++ .../test/ext/skills/catalogBuild.test.ts | 14 +++ 6 files changed, 240 insertions(+), 23 deletions(-) diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index 7268b2044d..3111258a4d 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -16,6 +16,7 @@ import { parseSkillMarkdown, resolveSkillRef, sha256Digest, + skillFileKey, skillRootUri, splitFrontmatter } from '@modelcontextprotocol/core-internal'; @@ -50,6 +51,7 @@ export interface SkillListing { /** A skill file as read and checked by {@linkcode SkillsClient.read}. */ export interface SkillFile { + /** The URI requested: the entry's spelling of the file. */ uri: string; mimeType?: string; /** Exactly the bytes the entry's digest covers. */ @@ -74,6 +76,46 @@ function decodeBlob(uri: string, blob: string): Uint8Array { } } +/** What `read` requests and checks: the entry's spelling of the file, its listing, and whether it is the skill's own `SKILL.md`. */ +interface ReadTarget { + uri: string; + /** `undefined` for `"dynamic"` skills. */ + listed: SkillResourceEntry | undefined; + manifest: boolean; +} + +/** + * Resolves `ref` against the skill root, drops its fragment, and finds the file in the entry: by its exact spelling + * first, then by {@linkcode skillFileKey}, since servers percent-encode names differently (`Q&A.md`, `Q%26A.md`). + * Throws `unlisted` for a ref that does not parse or names no file of the skill. + */ +function resolveTarget(skill: Skill, ref: string | undefined): ReadTarget { + const root = skillRootUri(skill.uri); + if (root === undefined) throw new TypeError(`${skill.uri} is not a SKILL.md URI`); + let uri: string; + try { + uri = resolveSkillRef(skill.uri, ref); + } catch (error) { + const given = ref ?? ''; + throw new SkillVerificationError('unlisted', given, `${given} is not a URI reference`, [], { cause: error }); + } + const key = skillFileKey(root, uri) ?? uri; + const manifest = key === skillFileKey(root, skill.uri); + const notInSkill = () => new SkillVerificationError('unlisted', uri, `${uri} is not a file of the skill ${skill.uri}`); + if (skill.resources === 'dynamic') { + if (!manifest && !isWithin(root, key)) throw notInSkill(); + return { uri: key, listed: undefined, manifest }; + } + const { resources } = skill; + const hash = uri.indexOf('#'); + const exact = hash === -1 ? uri : uri.slice(0, hash); + const listed = + resources.find(resource => canonicalUri(resource.uri) === exact) ?? + resources.find(resource => skillFileKey(root, resource.uri) === key); + if (listed === undefined) throw notInSkill(); + return { uri: listed.uri, listed, manifest }; +} + function unsupported(method: string): SdkError { return new SdkError( SdkErrorCode.CapabilityNotSupported, @@ -161,8 +203,10 @@ export class SkillsClient { /** * Reads one file of `skill` and verifies it. `ref` is a path relative to * the skill's root (`references/FORMS.md`) or an absolute URI; the - * default is the skill's own `SKILL.md`. The file must be listed in the - * entry (checked before any request), and its size and digest must match; + * default is the skill's own `SKILL.md`; a fragment is ignored. The file + * must be listed in the entry (checked before any request, however either + * side percent-encodes the name) and is requested by the entry's URI; its + * size and digest must match; * for the skill's own `SKILL.md` the frontmatter must also equal the * entry's. A failed check on what may be a cached copy is fetched once * more with `cacheMode: 'refresh'`. `"dynamic"` skills skip the listing, @@ -173,31 +217,19 @@ export class SkillsClient { */ async read(skill: Skill, ref?: string, options?: CacheableRequestOptions): Promise { if (!this.supported) throw unsupported('resources/read'); - const root = skillRootUri(skill.uri); - if (root === undefined) throw new TypeError(`${skill.uri} is not a SKILL.md URI`); - const uri = resolveSkillRef(skill.uri, ref); - const canonical = canonicalUri(uri); - const listed = - skill.resources === 'dynamic' ? undefined : skill.resources.find(resource => canonicalUri(resource.uri) === canonical); - const inSkill = skill.resources === 'dynamic' ? canonical === canonicalUri(skill.uri) || isWithin(root, uri) : listed !== undefined; - if (!inSkill) throw new SkillVerificationError('unlisted', uri, `${uri} is not a file of the skill ${skill.uri}`); - const target = listed?.uri ?? uri; + const target = resolveTarget(skill, ref); try { - return await this.#readVerified(skill, target, listed, options); + return await this.#readVerified(skill, target, options); } catch (error) { const mayBeCached = options?.cacheMode === undefined || options.cacheMode === 'use'; if (!(error instanceof SkillVerificationError) || !mayBeCached) throw error; // The failing copy may have come from the response cache; the spec requires fetching it again. - return this.#readVerified(skill, target, listed, { ...options, cacheMode: 'refresh' }); + return this.#readVerified(skill, target, { ...options, cacheMode: 'refresh' }); } } - async #readVerified( - skill: Skill, - uri: string, - listed: SkillResourceEntry | undefined, - options: CacheableRequestOptions | undefined - ): Promise { + async #readVerified(skill: Skill, target: ReadTarget, options: CacheableRequestOptions | undefined): Promise { + const { uri, listed } = target; const result = await this.#client.readResource({ uri }, options); const content = result.contents.find(item => canonicalUri(item.uri) === canonicalUri(uri)) ?? result.contents[0]; if (content === undefined) throw new SdkError(SdkErrorCode.InvalidResult, `resources/read returned no contents for ${uri}`); @@ -218,7 +250,7 @@ export class SkillsClient { ...('text' in content && { text: content.text }), verified: listed !== undefined }; - if (canonicalUri(uri) !== canonicalUri(skill.uri)) return file; + if (!target.manifest) return file; const markdown = 'text' in content ? content.text : decodeUtf8Lossless(bytes); if (markdown === undefined) throw new SkillVerificationError('frontmatter', uri, `${uri} is not valid UTF-8`); const { frontmatter, body } = this.#parse(uri, markdown); diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts index 946dc182d9..9f87f450f6 100644 --- a/packages/client/test/ext/skills/read.test.ts +++ b/packages/client/test/ext/skills/read.test.ts @@ -1,5 +1,5 @@ import type { Skill } from '@modelcontextprotocol/core/ext/skills'; -import { encodeUtf8, SdkErrorCode, sha256Digest } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, encodeUtf8, SdkErrorCode, sha256Digest } from '@modelcontextprotocol/core-internal'; import { describe, expect, it, vi } from 'vitest'; import type { SkillsClientOptions } from '../../../src/ext/skills/index'; @@ -24,6 +24,17 @@ async function connected({ files = PDF_FILES, options = {}, readTtlMs }: Setup = return { skill, served, calls, skills: new SkillsClient(client, options) }; } +/** A server that publishes exactly these URIs, however it spells them, with these contents. */ +async function publishedAs(contents: ReadonlyMap) { + const { skill, problems } = await buildSkillEntry( + MANIFEST, + [...contents].map(([uri, text]) => ({ uri, bytes: encodeUtf8(text) })) + ); + if (skill === undefined || problems.length > 0) throw new Error(problems.join('; ')); + const { client, calls } = await scriptedClient(CAPABILITIES, skillsHandlers([skill], contents)); + return { skill, calls, skills: new SkillsClient(client) }; +} + const reads = (calls: Call[]): unknown[] => calls.filter(call => call.method === 'resources/read').map(call => call.params['uri']); describe('SkillsClient.read', () => { @@ -54,6 +65,82 @@ describe('SkillsClient.read', () => { expect(await skills.read(skill, 'templates/Purchase Order.md')).toMatchObject({ uri: published, text: '# PO\n', verified: true }); }); + it('resolves non-ASCII file names to the URI the server published', async () => { + const { skills, skill } = await connected({ files: { ...PDF_FILES, 'references/résumé.md': '# CV\n' } }); + const published = uriOf(PDF_ROOT, 'references/résumé.md'); + expect(published).toBe('skill://pdf-processing/references/r%C3%A9sum%C3%A9.md'); + for (const ref of ['references/résumé.md', 'references/r%c3%a9sum%c3%a9.md']) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: published, text: '# CV\n', verified: true }); + } + }); + + it('matches reserved characters in a name however the ref spells them', async () => { + const { skills, skill, calls } = await connected({ files: { ...PDF_FILES, 'references/Q&A.md': '# Q&A\n' } }); + const published = uriOf(PDF_ROOT, 'references/Q&A.md'); + expect(published).toBe('skill://pdf-processing/references/Q&A.md'); + for (const ref of ['references/Q&A.md', 'references/Q%26A.md']) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: published, text: '# Q&A\n', verified: true }); + } + expect(reads(calls)).toEqual([published, published]); + }); + + it('verifies a file a server published with more encoding than ours, requesting its spelling', async () => { + const overEncoded = `${PDF_ROOT}/references/Q%26A.md`; + const { skills, skill, calls } = await publishedAs( + new Map([ + [MANIFEST, PDF_SKILL_MD], + [overEncoded, '# Q&A\n'] + ]) + ); + for (const ref of ['references/Q&A.md', 'references/Q%26A.md']) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: overEncoded, text: '# Q&A\n', verified: true }); + } + expect(reads(calls)).toEqual([overEncoded, overEncoded]); + }); + + it('prefers the exact spelling when an entry lists two spellings of one file', async () => { + const plain = `${PDF_ROOT}/references/Q&A.md`; + const encoded = `${PDF_ROOT}/references/Q%26A.md`; + const { skills, skill } = await publishedAs( + new Map([ + [MANIFEST, PDF_SKILL_MD], + [plain, '# plain\n'], + [encoded, '# encoded\n'] + ]) + ); + for (const ref of ['references/Q&A.md', 'references/Q&A.md#part']) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: plain, text: '# plain\n' }); + } + expect(await skills.read(skill, 'references/Q%26A.md')).toMatchObject({ uri: encoded, text: '# encoded\n' }); + }); + + it("checks SKILL.md's frontmatter however the ref spells it", async () => { + const alias = `${PDF_ROOT}/%53KILL.md`; + const { skills, skill } = await publishedAs( + new Map([ + [MANIFEST, PDF_SKILL_MD], + [alias, PDF_SKILL_MD.replace('Extract text and fill forms in PDF files.', 'Something else.')] + ]) + ); + await expect(skills.read(skill, '%53KILL.md')).rejects.toMatchObject({ reason: 'frontmatter', uri: alias }); + }); + + it('drops a fragment from the ref', async () => { + const { skills, skill, calls } = await connected(); + expect(await skills.read(skill, 'references/FORMS.md#fields')).toMatchObject({ uri: FORMS, verified: true }); + const dynamic: Skill = { ...skill, resources: 'dynamic' }; + expect(await skills.read(dynamic, 'references/FORMS.md#fields')).toMatchObject({ uri: FORMS, verified: false }); + expect(reads(calls)).toEqual([FORMS, FORMS]); + }); + + it('refuses a ref that is not a URI reference, before any request', async () => { + const { skills, skill, calls } = await connected(); + const error = await skills.read(skill, 'http://[').catch((error: unknown) => error); + expect(error).toBeInstanceOf(SkillVerificationError); + expect(error).toMatchObject({ reason: 'unlisted', uri: 'http://[' }); + expect(reads(calls)).toEqual([]); + }); + it('refuses unlisted files before sending any request', async () => { const { skills, skill, calls } = await connected(); for (const ref of ['references/MISSING.md', '../../etc/passwd', 'skill://other-skill/SKILL.md', 'https://example.com/x']) { diff --git a/packages/core-internal/src/ext/skills/index.ts b/packages/core-internal/src/ext/skills/index.ts index ff805489e8..af6ccef193 100644 --- a/packages/core-internal/src/ext/skills/index.ts +++ b/packages/core-internal/src/ext/skills/index.ts @@ -15,5 +15,6 @@ export { joinUri, lastSegmentOf, resolveSkillRef, + skillFileKey, skillRootUri } from './uri'; diff --git a/packages/core-internal/src/ext/skills/uri.ts b/packages/core-internal/src/ext/skills/uri.ts index c28c8c9989..3bb7bc84a5 100644 --- a/packages/core-internal/src/ext/skills/uri.ts +++ b/packages/core-internal/src/ext/skills/uri.ts @@ -1,6 +1,19 @@ -/** Percent-encodes one path segment; the result is stable under `new URL()`. */ +/** + * Every character a segment cannot carry as it is: WHATWG URL's path percent-encode set (controls, space, + * `"#<>?^\`{}`, DEL and non-ASCII) plus `%`, `/` and `\`, which would start an escape or split the segment. + */ +const ENCODED_IN_SEGMENT = /[^\w!$&'()*+,.:;=@[\]|~-]/gu; + +/** + * Percent-encodes one path segment the way WHATWG URL serialises it, so `new URL(name, root)` on a host yields the + * published URI: sub-delimiters such as `&`, `+`, `@` and `[` stay as they are, and `%`, `/`, `?`, `#` and `\` are + * always encoded. The dots of a `.` or `..` segment are encoded too; WHATWG URL still reads `%2E` as a dot, so such + * a URI fails {@linkcode isRoundTripUri} and {@linkcode isWithin} instead of naming the parent. Throws `URIError` on + * a lone surrogate. + */ export function encodeSegment(segment: string): string { - return encodeURIComponent(segment); + if (segment === '.' || segment === '..') return segment.replaceAll('.', '%2E'); + return segment.replaceAll(ENCODED_IN_SEGMENT, char => encodeURIComponent(char)); } /** `base` + `/`-joined encoded segments. `base` is `scheme://` or a URI without a trailing slash. */ @@ -69,6 +82,30 @@ export function isWithin(rootUri: string, uri: string): boolean { }); } +/** + * The one spelling every way of writing a file's URI below `rootUri` shares: canonical, without its fragment, and + * each segment below the root decoded and re-encoded with {@linkcode encodeSegment}. `Q&A.md`, `Q%26A.md` and + * `%51&A.md` key alike, as do `%c3%a9` and `%C3%A9`. A URI outside the root, or with a query, keys as its canonical + * form without the fragment; `undefined` if it does not parse. + */ +export function skillFileKey(rootUri: string, uri: string): string | undefined { + let url: URL; + try { + url = new URL(uri); + } catch { + return undefined; + } + url.hash = ''; + const canonical = url.href; + // A canonical path never holds a raw `?`, so one here starts a query, even an empty one. + if (canonical.includes('?') || !canonical.startsWith(`${rootUri}/`)) return canonical; + const below = canonical + .slice(rootUri.length + 1) + .split('/') + .map(segment => encodeSegment(safeDecode(segment))); + return `${rootUri}/${below.join('/')}`; +} + /** Resolves `ref` (default `SKILL.md`) against the skill's root, as a browser resolves a link. */ export function resolveSkillRef(skillMdUri: string, ref?: string): string { const root = skillRootUri(skillMdUri); diff --git a/packages/core-internal/test/ext/skills/uri.test.ts b/packages/core-internal/test/ext/skills/uri.test.ts index 95e6a2e88c..8788ad8cd5 100644 --- a/packages/core-internal/test/ext/skills/uri.test.ts +++ b/packages/core-internal/test/ext/skills/uri.test.ts @@ -3,11 +3,13 @@ import { describe, expect, it } from 'vitest'; import { canonicalUri, compareUris, + encodeSegment, isRoundTripUri, isWithin, joinUri, lastSegmentOf, resolveSkillRef, + skillFileKey, skillRootUri } from '../../../src/ext/skills/uri'; @@ -19,6 +21,50 @@ describe('uri helpers', () => { expect(joinUri('github://owner/repo/skills/', ['x', 'SKILL.md'])).toBe('github://owner/repo/skills/x/SKILL.md'); }); + it.each(['Q&A.md', 'c++.md', '[draft].md', 'me@home.md', 'a,b;c=d$e|f!(g)*~h.md', 'Purchase Order.md', 'Remboursé.md', 'x^y{z}.md'])( + 'publishes %s exactly as new URL() resolves the plain name against the root', + name => { + const published = joinUri('skill://acme/refunds', [name]); + expect(published).toBe(new URL(name, 'skill://acme/refunds/').href); + expect(isRoundTripUri(published)).toBe(true); + } + ); + + it('always encodes the characters that would change what a segment means', () => { + expect(encodeSegment('100%.md')).toBe('100%25.md'); + expect(encodeSegment(String.raw`a/b?c#d\e`)).toBe('a%2Fb%3Fc%23d%5Ce'); + expect(encodeSegment('a:b.md')).toBe('a:b.md'); + expect(encodeSegment('Remboursé.md')).toBe('Rembours%C3%A9.md'); + }); + + it('encodes the dots of a "." or ".." segment, which WHATWG URL still reads as a dot segment', () => { + expect(encodeSegment('.')).toBe('%2E'); + expect(encodeSegment('..')).toBe('%2E%2E'); + expect(encodeSegment('...')).toBe('...'); + expect(isRoundTripUri(joinUri('skill://acme/refunds', ['..', 'x.md']))).toBe(false); + expect(isWithin('skill://acme/refunds', joinUri('skill://acme/refunds', ['..', 'x.md']))).toBe(false); + }); + + it('throws URIError for a lone surrogate', () => { + expect(() => encodeSegment('x\uD800.md')).toThrow(URIError); + expect(() => joinUri('skill://acme', ['empty\uDC00'])).toThrow(URIError); + }); + + it('keys every spelling of one file below the root alike', () => { + const root = 'skill://acme/refunds'; + for (const uri of ['skill://acme/refunds/Q&A.md', 'skill://acme/refunds/Q%26A.md', 'skill://acme/refunds/%51&A.md#part']) { + expect(skillFileKey(root, uri)).toBe('skill://acme/refunds/Q&A.md'); + } + expect(skillFileKey(root, 'skill://acme/refunds/r%c3%a9sum%C3%A9.md')).toBe('skill://acme/refunds/r%C3%A9sum%C3%A9.md'); + expect(skillFileKey(root, 'skill://acme/refunds/100%.md')).toBe('skill://acme/refunds/100%25.md'); + expect(skillFileKey(root, 'skill://acme/refunds/100%25.md')).toBe('skill://acme/refunds/100%25.md'); + expect(skillFileKey(root, 'skill://acme/refunds/x%2Fy.md')).toBe('skill://acme/refunds/x%2Fy.md'); + expect(skillFileKey(root, 'skill://acme/refunds/x.md?v=1')).toBe('skill://acme/refunds/x.md?v=1'); + expect(skillFileKey(root, 'skill://acme/refunds/x.md?')).toBe('skill://acme/refunds/x.md?'); + expect(skillFileKey(root, 'skill://acme/other/x.md#f')).toBe('skill://acme/other/x.md'); + expect(skillFileKey(root, 'not a uri')).toBeUndefined(); + }); + it('finds the skill root and its last segment', () => { expect(skillRootUri('skill://acme/billing/refunds/SKILL.md')).toBe('skill://acme/billing/refunds'); expect(skillRootUri('skill://git-workflow/SKILL.md')).toBe('skill://git-workflow'); diff --git a/packages/server/test/ext/skills/catalogBuild.test.ts b/packages/server/test/ext/skills/catalogBuild.test.ts index a32f22057a..db2aa4a019 100644 --- a/packages/server/test/ext/skills/catalogBuild.test.ts +++ b/packages/server/test/ext/skills/catalogBuild.test.ts @@ -102,6 +102,20 @@ describe('buildCatalogSnapshot', () => { ]); }); + it('publishes reserved characters in names as a host resolves the plain name', async () => { + const names = ['Q&A.md', 'c++.md', '[draft].md', 'me@home.md']; + const files = { + ...fixtureFiles(), + ...Object.fromEntries([...names, '100%.md'].map(name => [`git-workflow/references/${name}`, '# x\n'])) + }; + const snapshot = await buildCatalogSnapshot(memorySkillFiles(files), optionsWith().options); + const resources = snapshot.skills.get(GIT)?.resources; + const uris = resources === undefined || resources === 'dynamic' ? [] : resources.map(resource => resource.uri); + for (const name of names) expect(uris).toContain(new URL(`references/${name}`, 'skill://git-workflow/').href); + // `%` is always encoded: a raw one would read as the start of an escape. + expect(uris).toContain('skill://git-workflow/references/100%25.md'); + }); + it('puts a prefix in front of every URI', async () => { const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith({ prefix: ['shop'] }).options); expect(snapshot.ordered.every(skill => skill.uri.startsWith('skill://shop/'))).toBe(true); From 9a322bc42ffea01b3cedc6038a37bb1dbcb16ffc Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 07:21:40 +0300 Subject: [PATCH 20/45] fix(skills): encode the URI authority strictly and key stray percent signs joinUri encodes the segment that lands in the authority with encodeURIComponent, so grouping folders and prefixes such as me@home or acme:prod keep publishing. '^' is always encoded, whatever the runtime's URL parser does. skillFileKey decodes each valid escape on its own, so names with a stray '%' match by their plain names. --- .../client/src/ext/skills/skillsClient.ts | 2 +- packages/client/test/ext/skills/read.test.ts | 22 ++++- packages/core-internal/src/ext/skills/uri.ts | 84 ++++++++++++++++--- .../core-internal/test/ext/skills/uri.test.ts | 45 +++++++++- .../server/test/ext/skills/catalog.test.ts | 9 ++ .../test/ext/skills/catalogBuild.test.ts | 27 ++++++ 6 files changed, 174 insertions(+), 15 deletions(-) diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index 3111258a4d..daa15c562d 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -51,7 +51,7 @@ export interface SkillListing { /** A skill file as read and checked by {@linkcode SkillsClient.read}. */ export interface SkillFile { - /** The URI requested: the entry's spelling of the file. */ + /** The URI requested: the entry's spelling of the file; for `"dynamic"` skills, the resolved ref in its normalised form. */ uri: string; mimeType?: string; /** Exactly the bytes the entry's digest covers. */ diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts index 9f87f450f6..9da4cdcb51 100644 --- a/packages/client/test/ext/skills/read.test.ts +++ b/packages/client/test/ext/skills/read.test.ts @@ -1,5 +1,5 @@ import type { Skill } from '@modelcontextprotocol/core/ext/skills'; -import { buildSkillEntry, encodeUtf8, SdkErrorCode, sha256Digest } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, encodeUtf8, joinUri, SdkErrorCode, sha256Digest } from '@modelcontextprotocol/core-internal'; import { describe, expect, it, vi } from 'vitest'; import type { SkillsClientOptions } from '../../../src/ext/skills/index'; @@ -141,6 +141,26 @@ describe('SkillsClient.read', () => { expect(reads(calls)).toEqual([]); }); + it('reads a name with a stray % by its plain name', async () => { + const { skills, skill } = await connected({ files: { ...PDF_FILES, 'references/100% off.md': '# Sale\n' } }); + const published = uriOf(PDF_ROOT, 'references/100% off.md'); + expect(published).toBe('skill://pdf-processing/references/100%25%20off.md'); + expect(await skills.read(skill, 'references/100% off.md')).toMatchObject({ uri: published, text: '# Sale\n', verified: true }); + }); + + it.each(['me@home', 'acme:prod', '[draft]'])('reads and matches files of a skill under the %s folder or prefix', async first => { + const root = joinUri('skill://', [first, 'pdf-processing']); + expect(root).toBe(`skill://${encodeURIComponent(first)}/pdf-processing`); + const files = { ...PDF_FILES, 'references/Q&A.md': '# Q&A\n' }; + const skill = await entryFor(root, files); + const { client } = await scriptedClient(CAPABILITIES, skillsHandlers([skill], filesOf(root, files))); + const skills = new SkillsClient(client); + expect(await skills.read(skill)).toMatchObject({ uri: `${root}/SKILL.md`, verified: true }); + for (const ref of ['references/Q&A.md', 'references/Q%26A.md', `${root}/references/Q&A.md`]) { + expect(await skills.read(skill, ref)).toMatchObject({ uri: `${root}/references/Q&A.md`, text: '# Q&A\n', verified: true }); + } + }); + it('refuses unlisted files before sending any request', async () => { const { skills, skill, calls } = await connected(); for (const ref of ['references/MISSING.md', '../../etc/passwd', 'skill://other-skill/SKILL.md', 'https://example.com/x']) { diff --git a/packages/core-internal/src/ext/skills/uri.ts b/packages/core-internal/src/ext/skills/uri.ts index 3bb7bc84a5..79c334090e 100644 --- a/packages/core-internal/src/ext/skills/uri.ts +++ b/packages/core-internal/src/ext/skills/uri.ts @@ -1,25 +1,38 @@ /** * Every character a segment cannot carry as it is: WHATWG URL's path percent-encode set (controls, space, - * `"#<>?^\`{}`, DEL and non-ASCII) plus `%`, `/` and `\`, which would start an escape or split the segment. + * `"#<>?^\`{}`, DEL and non-ASCII) plus `%`, `/` and `\`, which would start an escape or split the segment. `^` is + * encoded on every runtime although older URL parsers (Node 20 and 22) leave it raw, so the published form does not + * depend on the runtime; {@linkcode skillFileKey} keys both spellings alike. */ const ENCODED_IN_SEGMENT = /[^\w!$&'()*+,.:;=@[\]|~-]/gu; +/** `text` with every character in {@linkcode ENCODED_IN_SEGMENT} percent-encoded as UTF-8. */ +function encodePathText(text: string): string { + return text.replaceAll(ENCODED_IN_SEGMENT, char => encodeURIComponent(char)); +} + /** * Percent-encodes one path segment the way WHATWG URL serialises it, so `new URL(name, root)` on a host yields the * published URI: sub-delimiters such as `&`, `+`, `@` and `[` stay as they are, and `%`, `/`, `?`, `#` and `\` are - * always encoded. The dots of a `.` or `..` segment are encoded too; WHATWG URL still reads `%2E` as a dot, so such - * a URI fails {@linkcode isRoundTripUri} and {@linkcode isWithin} instead of naming the parent. Throws `URIError` on - * a lone surrogate. + * always encoded. The dots of a `.` or `..` segment are encoded too; WHATWG URL still reads `%2E` as a dot segment, + * so such a URI does not survive {@linkcode isRoundTripUri} (the catalog then skips the file) rather than naming + * the directory or its parent. Throws `URIError` on a lone surrogate. */ export function encodeSegment(segment: string): string { if (segment === '.' || segment === '..') return segment.replaceAll('.', '%2E'); - return segment.replaceAll(ENCODED_IN_SEGMENT, char => encodeURIComponent(char)); + return encodePathText(segment); } -/** `base` + `/`-joined encoded segments. `base` is `scheme://` or a URI without a trailing slash. */ +/** + * `base` + `/`-joined encoded segments. `base` is `scheme://` or a URI without a trailing slash. After `scheme://` + * the first segment is the authority, where `@`, `:`, `[`, `]` and `|` would read as userinfo, a port or an IP + * literal, so it is encoded strictly with `encodeURIComponent`; path segments follow {@linkcode encodeSegment}. + */ export function joinUri(base: string, segments: readonly string[]): string { - const prefix = base.endsWith('://') ? base : `${base.replace(/\/+$/, '')}/`; - return prefix + segments.map(segment => encodeSegment(segment)).join('/'); + if (!base.endsWith('://')) return `${base.replace(/\/+$/, '')}/${segments.map(segment => encodeSegment(segment)).join('/')}`; + const [authority, ...path] = segments; + if (authority === undefined) return base; + return [base + encodeURIComponent(authority), ...path.map(segment => encodeSegment(segment))].join('/'); } /** WHATWG-serialised form of `uri`, or `undefined` if it does not parse. */ @@ -82,11 +95,58 @@ export function isWithin(rootUri: string, uri: string): boolean { }); } +function decodeOrUndefined(escapes: string): string | undefined { + try { + return decodeURIComponent(escapes); + } catch { + return undefined; + } +} + +/** The length of the UTF-8 sequence a lead byte announces: 1 for ASCII, and for bytes that cannot lead one. */ +function utf8Width(lead: number): number { + if (lead >= 0xf0) return 4; + if (lead >= 0xe0) return 3; + if (lead >= 0xc0) return 2; + return 1; +} + +/** A run of `%XX` escapes keyed: each valid UTF-8 sequence decoded and re-encoded; any other byte stays an escape, upper-cased. */ +function keyOfEscapes(run: string): string { + const upper = run.toUpperCase(); + let key = ''; + for (let at = 0; at < upper.length; ) { + const width = 3 * utf8Width(Number.parseInt(upper.slice(at + 1, at + 3), 16)); + const decoded = decodeOrUndefined(upper.slice(at, at + width)); + if (decoded === undefined) { + key += upper.slice(at, at + 3); + at += 3; + } else { + key += encodePathText(decoded); + at += width; + } + } + return key; +} + +/** Runs of `%XX` escapes; splitting on this puts them at odd indexes. */ +const ESCAPE_RUN = /((?:%[\dA-Fa-f]{2})+)/; + +/** One segment keyed: escape runs normalised, every other character (a stray `%` too) encoded as {@linkcode encodeSegment} does. */ +function keyOfSegment(segment: string): string { + return segment + .split(ESCAPE_RUN) + .map((part, index) => (index % 2 === 0 ? encodePathText(part) : keyOfEscapes(part))) + .join(''); +} + /** * The one spelling every way of writing a file's URI below `rootUri` shares: canonical, without its fragment, and - * each segment below the root decoded and re-encoded with {@linkcode encodeSegment}. `Q&A.md`, `Q%26A.md` and - * `%51&A.md` key alike, as do `%c3%a9` and `%C3%A9`. A URI outside the root, or with a query, keys as its canonical - * form without the fragment; `undefined` if it does not parse. + * each segment below the root decoded and re-encoded as {@linkcode encodeSegment} does. Each UTF-8 sequence of + * escapes is decoded on its own, so a stray `%` (`100% off.md`) leaves the rest decodable, and an escape that is not + * valid UTF-8 stays one, upper-cased. `Q&A.md`, `Q%26A.md` and `%51&A.md` key alike, as do `%c3%a9` and `%C3%A9`, + * and `^` and `%5E`. A URI outside the root, or with a query, keys as its canonical form without the fragment; + * `undefined` if it does not parse. */ export function skillFileKey(rootUri: string, uri: string): string | undefined { let url: URL; @@ -102,7 +162,7 @@ export function skillFileKey(rootUri: string, uri: string): string | undefined { const below = canonical .slice(rootUri.length + 1) .split('/') - .map(segment => encodeSegment(safeDecode(segment))); + .map(segment => keyOfSegment(segment)); return `${rootUri}/${below.join('/')}`; } diff --git a/packages/core-internal/test/ext/skills/uri.test.ts b/packages/core-internal/test/ext/skills/uri.test.ts index 8788ad8cd5..37afe90f8a 100644 --- a/packages/core-internal/test/ext/skills/uri.test.ts +++ b/packages/core-internal/test/ext/skills/uri.test.ts @@ -21,7 +21,7 @@ describe('uri helpers', () => { expect(joinUri('github://owner/repo/skills/', ['x', 'SKILL.md'])).toBe('github://owner/repo/skills/x/SKILL.md'); }); - it.each(['Q&A.md', 'c++.md', '[draft].md', 'me@home.md', 'a,b;c=d$e|f!(g)*~h.md', 'Purchase Order.md', 'Remboursé.md', 'x^y{z}.md'])( + it.each(['Q&A.md', 'c++.md', '[draft].md', 'me@home.md', 'a,b;c=d$e|f!(g)*~h.md', 'Purchase Order.md', 'Remboursé.md', 'x{y}z.md'])( 'publishes %s exactly as new URL() resolves the plain name against the root', name => { const published = joinUri('skill://acme/refunds', [name]); @@ -30,6 +30,31 @@ describe('uri helpers', () => { } ); + it('encodes ^ on every runtime, and keys a host that leaves it raw alike', () => { + const root = 'skill://acme/refunds'; + const published = joinUri(root, ['x^y.md']); + expect(published).toBe('skill://acme/refunds/x%5Ey.md'); + // Node 24 resolves `x^y.md` to `x%5Ey.md`; Node 20 and 22 leave the `^` raw. + for (const uri of [published, 'skill://acme/refunds/x^y.md', new URL('x^y.md', `${root}/`).href]) { + expect(skillFileKey(root, uri)).toBe(published); + } + }); + + it.each([ + ['me@home', 'me%40home'], + ['acme:prod', 'acme%3Aprod'], + ['[draft]', '%5Bdraft%5D'], + ['a|b', 'a%7Cb'] + ])('encodes a first segment %s strictly, since it is the authority of a skill:// URI', (first, authority) => { + const uri = joinUri('skill://', [first, 'refunds', 'SKILL.md']); + expect(uri).toBe(`skill://${authority}/refunds/SKILL.md`); + expect(isRoundTripUri(uri)).toBe(true); + expect(new URL(uri).host).toBe(authority); + expect(resolveSkillRef(uri, 'references/Q&A.md')).toBe(`skill://${authority}/refunds/references/Q&A.md`); + // Below the authority, path segments follow the path rules. + expect(joinUri(`skill://${authority}`, [first])).toBe(`skill://${authority}/${first}`); + }); + it('always encodes the characters that would change what a segment means', () => { expect(encodeSegment('100%.md')).toBe('100%25.md'); expect(encodeSegment(String.raw`a/b?c#d\e`)).toBe('a%2Fb%3Fc%23d%5Ce'); @@ -48,6 +73,7 @@ describe('uri helpers', () => { it('throws URIError for a lone surrogate', () => { expect(() => encodeSegment('x\uD800.md')).toThrow(URIError); expect(() => joinUri('skill://acme', ['empty\uDC00'])).toThrow(URIError); + expect(() => joinUri('skill://', ['x\uD800', 'SKILL.md'])).toThrow(URIError); }); it('keys every spelling of one file below the root alike', () => { @@ -65,6 +91,23 @@ describe('uri helpers', () => { expect(skillFileKey(root, 'not a uri')).toBeUndefined(); }); + it.each(['100% off.md', '50% résumé.md', 'x%zz é.md', 'a % b.md'])( + 'keys %s, a name with a stray %, as published whatever else needs encoding', + name => { + const root = 'skill://acme/refunds'; + expect(skillFileKey(root, new URL(name, `${root}/`).href)).toBe(joinUri(root, [name])); + expect(skillFileKey(root, joinUri(root, [name]))).toBe(joinUri(root, [name])); + } + ); + + it('keeps an escape that is not valid UTF-8, in upper case, and decodes the valid ones around it', () => { + const root = 'skill://acme/refunds'; + for (const uri of ['skill://acme/refunds/%ff%41.md', 'skill://acme/refunds/%FFA.md']) { + expect(skillFileKey(root, uri)).toBe('skill://acme/refunds/%FFA.md'); + } + expect(skillFileKey(root, 'skill://acme/refunds/%c3%a9%C3.md')).toBe('skill://acme/refunds/%C3%A9%C3.md'); + }); + it('finds the skill root and its last segment', () => { expect(skillRootUri('skill://acme/billing/refunds/SKILL.md')).toBe('skill://acme/billing/refunds'); expect(skillRootUri('skill://git-workflow/SKILL.md')).toBe('skill://git-workflow'); diff --git a/packages/server/test/ext/skills/catalog.test.ts b/packages/server/test/ext/skills/catalog.test.ts index 9a909151a0..120dca559f 100644 --- a/packages/server/test/ext/skills/catalog.test.ts +++ b/packages/server/test/ext/skills/catalog.test.ts @@ -33,6 +33,15 @@ describe('SkillCatalog serving', () => { }); }); + it('serves files under a prefix that needs strict encoding in the authority', async () => { + const { catalog, mcp } = await loaded({ prefix: 'acme:prod' }); + const git = 'skill://acme%3Aprod/git-workflow/SKILL.md'; + expect(catalog.entries.map(skill => skill.uri)).toContain(git); + expect(catalog.entries).toHaveLength(3); + const review = new URL('references/REVIEW.md', git).href; + expect(await resultOf(mcp, 'resources/read', { uri: review })).toMatchObject({ contents: [{ uri: review, text: '# Review\n' }] }); + }); + it('lists only SKILL.md resources in resources/list', async () => { const { mcp } = await loaded(); expect(await resultOf(mcp, 'resources/list')).toMatchObject({ diff --git a/packages/server/test/ext/skills/catalogBuild.test.ts b/packages/server/test/ext/skills/catalogBuild.test.ts index db2aa4a019..b61a32c4c9 100644 --- a/packages/server/test/ext/skills/catalogBuild.test.ts +++ b/packages/server/test/ext/skills/catalogBuild.test.ts @@ -121,6 +121,33 @@ describe('buildCatalogSnapshot', () => { expect(snapshot.ordered.every(skill => skill.uri.startsWith('skill://shop/'))).toBe(true); }); + it('encodes a top-level folder strictly, since it is the URI authority', async () => { + const files = { + 'me@home/refunds/SKILL.md': '---\nname: refunds\ndescription: d\n---\n', + 'me@home/refunds/references/Q&A.md': '# Q&A\n', + '[draft]/notes/SKILL.md': '---\nname: notes\ndescription: d\n---\n' + }; + const { options, warnings } = optionsWith(); + const snapshot = await buildCatalogSnapshot(memorySkillFiles(files), options); + expect(warnings).toEqual([]); + const uris = snapshot.ordered.map(skill => skill.uri); + expect(uris).toEqual(['skill://%5Bdraft%5D/notes/SKILL.md', 'skill://me%40home/refunds/SKILL.md']); + for (const uri of uris) expect(new URL(uri).href).toBe(uri); + const refunds = snapshot.skills.get('skill://me%40home/refunds/SKILL.md')?.resources; + const listed = refunds === undefined || refunds === 'dynamic' ? [] : refunds.map(resource => resource.uri); + expect(listed).toContain(new URL('references/Q&A.md', 'skill://me%40home/refunds/SKILL.md').href); + }); + + it('encodes a prefix strictly, so acme:prod is not read as a host and port', async () => { + const { options, warnings } = optionsWith({ prefix: ['acme:prod'] }); + const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), options); + expect(warnings).toEqual([]); + expect(snapshot.ordered).toHaveLength(3); + expect(snapshot.ordered.every(skill => skill.uri.startsWith('skill://acme%3Aprod/') && new URL(skill.uri).href === skill.uri)).toBe( + true + ); + }); + it('skips invalid skills and warns about each one', async () => { const files = { ...fixtureFiles(), From b7753ae158b9aec3f01837f1fe619599e1177339 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 07:31:13 +0300 Subject: [PATCH 21/45] fix(core-internal): key a stray percent-hex escape as a literal percent A %XX that does not start a valid UTF-8 sequence is keyed as %25 plus the original two characters, exactly as encodeSegment publishes such a name, so plain-name reads of files like "10%Dec.md" match their listing. --- packages/client/test/ext/skills/read.test.ts | 14 ++++-- packages/core-internal/src/ext/skills/uri.ts | 23 +++++---- .../core-internal/test/ext/skills/uri.test.ts | 50 ++++++++++++++----- 3 files changed, 59 insertions(+), 28 deletions(-) diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts index 9da4cdcb51..3780137c6b 100644 --- a/packages/client/test/ext/skills/read.test.ts +++ b/packages/client/test/ext/skills/read.test.ts @@ -141,11 +141,15 @@ describe('SkillsClient.read', () => { expect(reads(calls)).toEqual([]); }); - it('reads a name with a stray % by its plain name', async () => { - const { skills, skill } = await connected({ files: { ...PDF_FILES, 'references/100% off.md': '# Sale\n' } }); - const published = uriOf(PDF_ROOT, 'references/100% off.md'); - expect(published).toBe('skill://pdf-processing/references/100%25%20off.md'); - expect(await skills.read(skill, 'references/100% off.md')).toMatchObject({ uri: published, text: '# Sale\n', verified: true }); + it.each([ + ['100% off.md', '100%25%20off.md'], + ['10%Dec.md', '10%25Dec.md'], + ['%Café.md', '%25Caf%C3%A9.md'] + ])('reads %s, a name with a stray %, by its plain name', async (name, encoded) => { + const { skills, skill } = await connected({ files: { ...PDF_FILES, [`references/${name}`]: '# Sale\n' } }); + const published = uriOf(PDF_ROOT, `references/${name}`); + expect(published).toBe(`skill://pdf-processing/references/${encoded}`); + expect(await skills.read(skill, `references/${name}`)).toMatchObject({ uri: published, text: '# Sale\n', verified: true }); }); it.each(['me@home', 'acme:prod', '[draft]'])('reads and matches files of a skill under the %s folder or prefix', async first => { diff --git a/packages/core-internal/src/ext/skills/uri.ts b/packages/core-internal/src/ext/skills/uri.ts index 79c334090e..5674e5557e 100644 --- a/packages/core-internal/src/ext/skills/uri.ts +++ b/packages/core-internal/src/ext/skills/uri.ts @@ -111,15 +111,18 @@ function utf8Width(lead: number): number { return 1; } -/** A run of `%XX` escapes keyed: each valid UTF-8 sequence decoded and re-encoded; any other byte stays an escape, upper-cased. */ +/** + * A run of `%XX` escapes keyed: each valid UTF-8 sequence decoded and re-encoded. A `%XX` that starts none is a + * literal `%` before two characters, keyed `%25XX` with their case kept, exactly as {@linkcode encodeSegment} + * publishes such a name (`10%Dec.md` is `10%25Dec.md`). + */ function keyOfEscapes(run: string): string { - const upper = run.toUpperCase(); let key = ''; - for (let at = 0; at < upper.length; ) { - const width = 3 * utf8Width(Number.parseInt(upper.slice(at + 1, at + 3), 16)); - const decoded = decodeOrUndefined(upper.slice(at, at + width)); + for (let at = 0; at < run.length; ) { + const width = 3 * utf8Width(Number.parseInt(run.slice(at + 1, at + 3), 16)); + const decoded = decodeOrUndefined(run.slice(at, at + width)); if (decoded === undefined) { - key += upper.slice(at, at + 3); + key += `%25${run.slice(at + 1, at + 3)}`; at += 3; } else { key += encodePathText(decoded); @@ -143,10 +146,10 @@ function keyOfSegment(segment: string): string { /** * The one spelling every way of writing a file's URI below `rootUri` shares: canonical, without its fragment, and * each segment below the root decoded and re-encoded as {@linkcode encodeSegment} does. Each UTF-8 sequence of - * escapes is decoded on its own, so a stray `%` (`100% off.md`) leaves the rest decodable, and an escape that is not - * valid UTF-8 stays one, upper-cased. `Q&A.md`, `Q%26A.md` and `%51&A.md` key alike, as do `%c3%a9` and `%C3%A9`, - * and `^` and `%5E`. A URI outside the root, or with a query, keys as its canonical form without the fragment; - * `undefined` if it does not parse. + * escapes is decoded on its own, and a `%` that starts none is literal (keyed `%25`), so a stray `%` in a plain name + * (`100% off.md`, `10%Dec.md`) keys as published. `Q&A.md`, `Q%26A.md` and `%51&A.md` key alike, as do `%c3%a9` + * and `%C3%A9`, and `^` and `%5E`; `%ff` and `%FF` do not, being literal text. A URI outside the root, or with a + * query, keys as its canonical form without the fragment; `undefined` if it does not parse. */ export function skillFileKey(rootUri: string, uri: string): string | undefined { let url: URL; diff --git a/packages/core-internal/test/ext/skills/uri.test.ts b/packages/core-internal/test/ext/skills/uri.test.ts index 37afe90f8a..2167f97469 100644 --- a/packages/core-internal/test/ext/skills/uri.test.ts +++ b/packages/core-internal/test/ext/skills/uri.test.ts @@ -91,21 +91,45 @@ describe('uri helpers', () => { expect(skillFileKey(root, 'not a uri')).toBeUndefined(); }); - it.each(['100% off.md', '50% résumé.md', 'x%zz é.md', 'a % b.md'])( - 'keys %s, a name with a stray %, as published whatever else needs encoding', - name => { - const root = 'skill://acme/refunds'; - expect(skillFileKey(root, new URL(name, `${root}/`).href)).toBe(joinUri(root, [name])); - expect(skillFileKey(root, joinUri(root, [name]))).toBe(joinUri(root, [name])); - } - ); + it.each([ + '100% off.md', + '50% résumé.md', + 'x%zz é.md', + 'a % b.md', + '10%Dec.md', + '50%Fee.md', + '100%Beef.md', + '2%Ad.md', + '50%ff.md', + '100%Cake.md', + '20%E2 x.md', + '%Café.md' + ])('keys %s, a name with a stray %, as published whatever follows it', name => { + const root = 'skill://acme/refunds'; + expect(skillFileKey(root, new URL(name, `${root}/`).href)).toBe(joinUri(root, [name])); + expect(skillFileKey(root, joinUri(root, [name]))).toBe(joinUri(root, [name])); + }); - it('keeps an escape that is not valid UTF-8, in upper case, and decodes the valid ones around it', () => { + it.each(['10%Dec.md', '%FF', 'a%2Fb', 'é%', '😀%41', '%41.md'])('keys the published URI of %s as itself', name => { const root = 'skill://acme/refunds'; - for (const uri of ['skill://acme/refunds/%ff%41.md', 'skill://acme/refunds/%FFA.md']) { - expect(skillFileKey(root, uri)).toBe('skill://acme/refunds/%FFA.md'); - } - expect(skillFileKey(root, 'skill://acme/refunds/%c3%a9%C3.md')).toBe('skill://acme/refunds/%C3%A9%C3.md'); + const published = joinUri(root, [name]); + expect(skillFileKey(root, published)).toBe(published); + }); + + it('keys an escape that does not start valid UTF-8 as a literal %, as encodeSegment publishes it', () => { + const root = 'skill://acme/refunds'; + const key = (segment: string): string | undefined => skillFileKey(root, `${root}/${segment}`); + expect(key('%ff%41.md')).toBe(`${root}/%25ffA.md`); + expect(key('%c3%a9%C3.md')).toBe(`${root}/%C3%A9%25C3.md`); + // Overlong, surrogate, out-of-range and truncated sequences are not UTF-8: each of their `%` is literal. + expect(key('%C0%80')).toBe(`${root}/%25C0%2580`); + expect(key('%ED%A0%80')).toBe(`${root}/%25ED%25A0%2580`); + expect(key('%F4%90%80%80')).toBe(`${root}/%25F4%2590%2580%2580`); + expect(key('%e2%82')).toBe(`${root}/%25e2%2582`); + expect(key('%f0%9f%98%80')).toBe(`${root}/%F0%9F%98%80`); + // Decoding never carries a slash out of its segment. + expect(key('%2E%2E%2F')).toBe(`${root}/..%2F`); + expect(isWithin(root, `${root}/..%2F`)).toBe(false); }); it('finds the skill root and its last segment', () => { From 96a6d1e865155d4b9b59e9bce8e35a3a6cbb1016 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 07:49:21 +0300 Subject: [PATCH 22/45] fix(client): surface 2026-07-28 method-not-found responses as JSON-RPC errors A 2026-07-28 Streamable HTTP server answers an unimplemented method with HTTP 404 and a -32601 JSON-RPC body. The transport now surfaces that body in-band, as it already did for 400, so callers get ProtocolError(-32601) instead of an opaque SdkHttpError. Legacy-era exchanges are unchanged. --- packages/client/src/client/streamableHttp.ts | 15 +++++++--- .../client/test/client/streamableHttp.test.ts | 30 ++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 091674e101..f3ac6aa9e7 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1126,22 +1126,29 @@ export class StreamableHTTPClientTransport implements Transport { // SEP-2243 (and the rest of the inbound validation ladder) // emit ladder rejections as HTTP 400 carrying a JSON-RPC error - // response body. Surface those in-band so `Protocol._onresponse` + // response body, and an unimplemented method is answered + // HTTP 404 with a -32601 body (the 2026-07-28 Streamable HTTP + // MUST; the body is what tells it apart from a legacy 404). + // Surface those in-band so `Protocol._onresponse` // converts them to a typed `ProtocolError` matched to the // pending request id — instead of an opaque transport error. - // Any 400 whose body is not a well-formed JSON-RPC error + // Any 400/404 whose body is not a well-formed JSON-RPC error // response (or whose id does not match an outstanding request) // still falls through to the generic `SdkHttpError`. // // Modern-era only: gated on the outbound message carrying a // 2026-07-28 envelope claim (the same gate the body-derived // `mcp-method`/`mcp-name` headers use), so a legacy-era - // exchange keeps surfacing 400 as `SdkHttpError` exactly as + // exchange keeps surfacing 400/404 as `SdkHttpError` exactly as // before — the changeset's "legacy-era paths are unchanged" // claim stays true and existing // `e instanceof SdkHttpError && e.status === 400` callers do // not silently stop matching. - if (response.status === 400 && typeof text === 'string' && this._isModernEnvelopedRequest(message)) { + if ( + (response.status === 400 || response.status === 404) && + typeof text === 'string' && + this._isModernEnvelopedRequest(message) + ) { try { const parsed = JSONRPCMessageSchema.parse(JSON.parse(text)); const requests = (Array.isArray(message) ? message : [message]).filter(m => isJSONRPCRequest(m)); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 17fd2df276..50e941db10 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1,5 +1,5 @@ import type { JSONRPCMessage, JSONRPCRequest, OAuthTokens } from '@modelcontextprotocol/core-internal'; -import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal'; +import { OAuthError, OAuthErrorCode, PROTOCOL_VERSION_META_KEY, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal'; import type { Mock, Mocked } from 'vitest'; import type { OAuthClientProvider } from '../../src/client/auth'; @@ -369,6 +369,34 @@ describe('StreamableHTTPClientTransport', () => { expect(errorSpy).toHaveBeenCalled(); }); + it('delivers a 2026-07-28 HTTP 404 method-not-found JSON-RPC error in-band; legacy still throws SdkHttpError', async () => { + // 2026-07-28 Streamable HTTP: a server that does not implement the method MUST answer 404 with a + // -32601 JSON-RPC error, and that body is what tells it apart from a legacy server's 404. + const errorBody = { jsonrpc: '2.0', id: 1, error: { code: -32_601, message: 'Method not found' } }; + const fetch = vi.fn(async () => Response.json(errorBody, { status: 404 })); + const tx = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { fetch }); + const seen: JSONRPCMessage[] = []; + tx.onmessage = message => seen.push(message); + await tx.start(); + + const params = { uri: 'skill://notes' }; + await expect( + tx.send({ + jsonrpc: '2.0', + id: 1, + method: 'resources/directory/read', + params: { ...params, _meta: { [PROTOCOL_VERSION_META_KEY]: '2026-07-28' } } + }) + ).resolves.toBeUndefined(); + expect(seen).toEqual([errorBody]); + + await expect(tx.send({ jsonrpc: '2.0', id: 1, method: 'resources/directory/read', params })).rejects.toMatchObject({ + code: SdkErrorCode.ClientHttpNotImplemented, + status: 404 + }); + await tx.close(); + }); + it('should handle non-streaming JSON response', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0', From 07dd73c7f75ec3bba0ce5e30beff4150d969e0f3 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 07:49:31 +0300 Subject: [PATCH 23/45] test: skills integration and wire-behaviour suites End to end through public import paths: loadSkills on a temp folder, SkillsClient verifying every file, over createMcpHandler (one server per request) and stdio, on 2025-11-25 and 2026-07-28. The wire suite pins resources/list, resources/read, resources/templates/list, skills/* and the error codes, including app-resource precedence, listResources and baseUri; the SEP-3371 middleware version must pass it unchanged. Co-authored-by: Matt Carey --- .../test/__fixtures__/skillsStdioServer.ts | 27 +++ test/integration/test/skills/skillTree.ts | 37 ++++ .../test/skills/skillsEndToEnd.test.ts | 159 ++++++++++++++++ .../test/skills/skillsWire.test.ts | 180 ++++++++++++++++++ test/integration/tsconfig.json | 3 + 5 files changed, 406 insertions(+) create mode 100644 test/integration/test/__fixtures__/skillsStdioServer.ts create mode 100644 test/integration/test/skills/skillTree.ts create mode 100644 test/integration/test/skills/skillsEndToEnd.test.ts create mode 100644 test/integration/test/skills/skillsWire.test.ts diff --git a/test/integration/test/__fixtures__/skillsStdioServer.ts b/test/integration/test/__fixtures__/skillsStdioServer.ts new file mode 100644 index 0000000000..7a58dd2258 --- /dev/null +++ b/test/integration/test/__fixtures__/skillsStdioServer.ts @@ -0,0 +1,27 @@ +/** + * A skills server over stdio: `loadSkills()` once, attached + * to every server the dual-era `serveStdio` entry creates. Spawned by + * `test/skills/skillsEndToEnd.test.ts`. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +const root = process.argv[2]; +if (root === undefined) throw new Error('usage: skillsStdioServer.ts '); + +const skills = await loadSkills(root); +const handle = serveStdio(() => { + const mcp = new McpServer({ name: 'skills-stdio-fixture', version: '1.0.0' }); + skills.attach(mcp); + return mcp; +}); + +const exit = async () => { + await handle.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}; + +process.on('SIGINT', exit); +process.on('SIGTERM', exit); diff --git a/test/integration/test/skills/skillTree.ts b/test/integration/test/skills/skillTree.ts new file mode 100644 index 0000000000..6a7fc587f0 --- /dev/null +++ b/test/integration/test/skills/skillTree.ts @@ -0,0 +1,37 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const PNG = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]); + +/** + * A skills folder with everything that tends to break: a Windows-authored + * SKILL.md (BOM + CRLF), a nested skill under organisational folders, a file + * name with a space and a binary file. Written to a temp dir at test time, so + * no formatter or git line-ending rule can touch the bytes. + */ +export const SKILL_TREE: Readonly> = { + 'git-workflow/SKILL.md': + '---\nname: git-workflow\ndescription: Branching and review conventions.\n---\n# Git workflow\n\nRead references/REVIEW.md before opening a PR.\n', + 'git-workflow/references/REVIEW.md': '# Review\n\n- tests pass\n', + 'acme/billing/refunds/SKILL.md': '---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\n# Refunds\r\n', + 'acme/billing/refunds/templates/Purchase Order.md': '# PO\n', + 'acme/billing/refunds/assets/logo.png': PNG, + 'acme/billing/refunds/escalation/SKILL.md': '---\nname: escalation\ndescription: Escalate a refund.\n---\n# Escalation\n' +}; + +export const GIT = 'skill://git-workflow/SKILL.md'; +export const REFUNDS = 'skill://acme/billing/refunds/SKILL.md'; +export const ESCALATION = 'skill://acme/billing/refunds/escalation/SKILL.md'; +export const PURCHASE_ORDER = 'skill://acme/billing/refunds/templates/Purchase%20Order.md'; + +/** Writes `files` under a fresh temp dir and returns its path. */ +export async function writeSkillTree(files: Readonly> = SKILL_TREE): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'mcp-skills-it-')); + for (const [relative, content] of Object.entries(files)) { + const target = path.join(root, ...relative.split('/')); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + } + return root; +} diff --git a/test/integration/test/skills/skillsEndToEnd.test.ts b/test/integration/test/skills/skillsEndToEnd.test.ts new file mode 100644 index 0000000000..2b924f6829 --- /dev/null +++ b/test/integration/test/skills/skillsEndToEnd.test.ts @@ -0,0 +1,159 @@ +/** + * Skills over MCP end to end, through public import paths only: a folder on + * disk served by `loadSkills`, read and verified by `SkillsClient`, over HTTP + * (`createMcpHandler`, one server per request) and stdio (a child process), on + * both protocol versions. + */ +import { rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import type { SkillCatalog } from '@modelcontextprotocol/server/ext/skills'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { ESCALATION, GIT, PURCHASE_ORDER, REFUNDS, writeSkillTree } from './skillTree'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; +const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__'); + +function clientFor(revision: string): Client { + return new Client( + { name: 'skills-host', version: '1.0.0' }, + revision === MODERN ? { versionNegotiation: { mode: { pin: MODERN } } } : {} + ); +} + +async function overHttp(catalog: SkillCatalog, revision: string, onServer: () => void = () => {}): Promise { + const handler = createMcpHandler(() => { + onServer(); + const mcp = new McpServer({ name: 'skills-http', version: '1.0.0' }); + catalog.attach(mcp); + return mcp; + }); + const client = clientFor(revision); + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { fetch: (url, init) => handler.fetch(new Request(url, init)) }) + ); + return client; +} + +async function overStdio(root: string, revision: string): Promise { + const client = clientFor(revision); + await client.connect( + new StdioClientTransport({ command: process.execPath, args: ['--import', 'tsx', 'skillsStdioServer.ts', root], cwd: FIXTURES_DIR }) + ); + return client; +} + +let root: string; +let catalog: SkillCatalog; + +beforeAll(async () => { + root = await writeSkillTree(); + catalog = await loadSkills(root); +}); + +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe.each([ + ['http', LEGACY], + ['http', MODERN], + ['stdio', LEGACY], + ['stdio', MODERN] +])('skills over %s on %s', (transport, revision) => { + let client: Client; + let skills: SkillsClient; + + beforeAll(async () => { + client = transport === 'http' ? await overHttp(catalog, revision) : await overStdio(root, revision); + skills = new SkillsClient(client); + }, 30_000); + + afterAll(async () => { + await client.close(); + }); + + it('declares the extension with directoryRead', () => { + expect(skills.supported).toBe(true); + expect(skills.directoryRead).toBe(true); + }); + + it('lists every skill, and every file of every skill verifies', async () => { + const { skills: entries, invalid } = await skills.list(); + expect(invalid).toEqual([]); + expect(entries.map(entry => entry.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + for (const entry of entries) { + const manifest = await skills.read(entry); + expect(manifest).toMatchObject({ verified: true, frontmatter: entry.frontmatter }); + for (const resource of entry.resources === 'dynamic' ? [] : entry.resources) { + expect(await skills.read(entry, resource.uri)).toMatchObject({ uri: resource.uri, verified: true }); + } + } + }); + + it('keeps a Windows-authored SKILL.md byte for byte', async () => { + const refunds = await skills.get(REFUNDS); + const manifest = await skills.read(refunds); + expect(manifest.text?.startsWith('---\r\n')).toBe(true); + expect(manifest.frontmatter).toEqual({ name: 'refunds', description: 'Refund workflow.' }); + }); + + it('gets a skill and reads files by relative path, including names with a space', async () => { + const git = await skills.get(GIT); + expect(await skills.read(git, 'references/REVIEW.md')).toMatchObject({ text: '# Review\n\n- tests pass\n', verified: true }); + const refunds = await skills.get(REFUNDS); + expect(await skills.read(refunds, 'templates/Purchase Order.md')).toMatchObject({ uri: PURCHASE_ORDER, verified: true }); + }); + + it('reads a skill directory', async () => { + const children = await skills.readDirectory('skill://acme/billing/refunds'); + expect(children.map(child => child.name)).toEqual(['SKILL.md', 'assets', 'escalation', 'templates']); + }); + + it('sends caching fields on 2026-07-28 only', async () => { + const listing = await skills.list(); + if (revision === MODERN) expect(listing).toMatchObject({ ttlMs: 0, cacheScope: 'private' }); + else expect(listing).not.toHaveProperty('ttlMs'); + }); + + it('lists only SKILL.md files in resources/list', async () => { + const { resources } = await client.listResources(); + expect(resources.map(resource => resource.uri)).toEqual([REFUNDS, ESCALATION, GIT]); + }); +}); + +describe('one catalog behind per-request HTTP servers', () => { + it('serves every request from a fresh server', async () => { + let servers = 0; + const client = await overHttp(catalog, MODERN, () => { + servers++; + }); + const skills = new SkillsClient(client); + const { skills: entries } = await skills.list(); + for (const entry of entries) await skills.read(entry); + expect(servers).toBeGreaterThan(3); + await client.close(); + }); + + it('refuses an edited file until reload(), then serves the new version', async () => { + const editable = await writeSkillTree(); + const own = await loadSkills(editable, { onWarning: () => {} }); + const client = await overHttp(own, MODERN); + const skills = new SkillsClient(client); + const before = await skills.get(GIT); + await writeFile(path.join(editable, 'git-workflow', 'references', 'REVIEW.md'), '# Review v2\n'); + await expect(skills.read(before, 'references/REVIEW.md')).rejects.toMatchObject({ code: -32_603 }); + expect(await own.reload()).toEqual({ added: [], removed: [], changed: [GIT] }); + const after = await skills.get(GIT); + expect(await skills.read(after, 'references/REVIEW.md')).toMatchObject({ text: '# Review v2\n', verified: true }); + await client.close(); + await rm(editable, { recursive: true, force: true }); + }); +}); diff --git a/test/integration/test/skills/skillsWire.test.ts b/test/integration/test/skills/skillsWire.test.ts new file mode 100644 index 0000000000..8a3f88dab1 --- /dev/null +++ b/test/integration/test/skills/skillsWire.test.ts @@ -0,0 +1,180 @@ +/** + * Wire behaviour of the Skills extension, as a client sees it. This suite is + * the contract for the SEP-3371 version (JSON-RPC middleware instead of a + * resource template and handlers): it must pass unchanged against it. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import type { SkillCatalog, SkillSource } from '@modelcontextprotocol/server/ext/skills'; +import { memorySkillFiles, skillEntry, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; +const EXTENSION = 'io.modelcontextprotocol/skills'; +const SERVER_INFO = 'io.modelcontextprotocol/serverInfo'; +const Loose = z.looseObject({}); + +const GIT_SKILL_MD = '---\nname: git-workflow\ndescription: Branching and review conventions.\n---\n# Git workflow\n'; +const REVIEW_MD = '# Review\n'; +const NOTES_MD = '---\nname: notes\ndescription: Team notes.\n---\n# Notes\n'; +const GIT = 'skill://git-workflow/SKILL.md'; +const REVIEW = 'skill://git-workflow/references/REVIEW.md'; + +let catalog: SkillCatalog; +const open: Client[] = []; + +beforeAll(async () => { + catalog = await SkillsExtension.load( + memorySkillFiles({ 'git-workflow/SKILL.md': GIT_SKILL_MD, 'git-workflow/references/REVIEW.md': REVIEW_MD }) + ); +}); + +afterEach(async () => { + await Promise.all(open.splice(0).map(client => client.close())); +}); + +async function connect(revision: string, build: (mcp: McpServer) => void): Promise { + const handler = createMcpHandler(() => { + const mcp = new McpServer({ name: 'skills-wire', version: '1.0.0' }); + build(mcp); + return mcp; + }); + const client = new Client( + { name: 'wire-client', version: '1.0.0' }, + revision === MODERN ? { versionNegotiation: { mode: { pin: MODERN } } } : {} + ); + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { fetch: (url, init) => handler.fetch(new Request(url, init)) }) + ); + open.push(client); + return client; +} + +function raw(client: Client, method: string, params?: Record): Promise> { + return client.request({ method, ...(params !== undefined && { params }) }, Loose); +} + +async function notesSource(withListing: boolean): Promise { + const notes = await skillEntry('notes', { 'SKILL.md': NOTES_MD }); + return { + list: async () => ({ skills: [notes] }), + get: async uri => (uri === notes.uri ? notes : undefined), + read: async uri => (uri === notes.uri ? { text: NOTES_MD, mimeType: 'text/markdown' } : undefined), + ...(withListing && { listResources: async () => [{ uri: notes.uri, name: 'notes', mimeType: 'text/markdown' }] }) + }; +} + +describe.each([LEGACY, MODERN])('skills wire behaviour on %s', revision => { + // A 2026-07-28 result carries the cache fields and, in `_meta`, the server's identity (ResultMetaObject). + const envelope = + revision === MODERN ? { _meta: { [SERVER_INFO]: { name: 'skills-wire', version: '1.0.0' } }, ttlMs: 0, cacheScope: 'private' } : {}; + + it('declares the extension next to resources', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + expect(client.getServerCapabilities()).toMatchObject({ resources: {}, extensions: { [EXTENSION]: { directoryRead: true } } }); + }); + + it('publishes one resource template', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + const { resourceTemplates } = await raw(client, 'resources/templates/list'); + expect(resourceTemplates).toEqual([{ name: EXTENSION, uriTemplate: 'skill://{+path}' }]); + }); + + it('lists each SKILL.md in resources/list', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + const { resources } = await raw(client, 'resources/list'); + expect(resources).toEqual([ + { + uri: GIT, + name: 'git-workflow', + description: 'Branching and review conventions.', + mimeType: 'text/markdown', + size: GIT_SKILL_MD.length + } + ]); + }); + + it('serves files through resources/read', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + const { contents } = await raw(client, 'resources/read', { uri: REVIEW }); + expect(contents).toEqual([{ uri: REVIEW, mimeType: 'text/markdown', text: REVIEW_MD }]); + }); + + it('answers skills/list, skills/get and resources/directory/read', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + expect(await raw(client, 'skills/list')).toEqual({ skills: catalog.entries, ...envelope }); + expect(await raw(client, 'skills/get', { uri: GIT })).toEqual({ skill: catalog.entries[0], ...envelope }); + expect(await raw(client, 'resources/directory/read', { uri: 'skill://git-workflow' })).toEqual({ + resources: [ + { uri: GIT, name: 'SKILL.md', mimeType: 'text/markdown', size: GIT_SKILL_MD.length }, + { uri: 'skill://git-workflow/references', name: 'references', mimeType: 'inode/directory' } + ], + ...envelope + }); + }); + + it('uses the agreed error codes', async () => { + const client = await connect(revision, mcp => catalog.attach(mcp)); + await expect(raw(client, 'resources/read', { uri: 'skill://nope/x.md' })).rejects.toMatchObject({ code: -32_602 }); + await expect(raw(client, 'resources/read', { uri: 'skill://git-workflow' })).rejects.toMatchObject({ + code: -32_602, + message: expect.stringMatching(/is a directory/) + }); + await expect(raw(client, 'skills/get', { uri: 'skill://nope/SKILL.md' })).rejects.toMatchObject({ code: -32_602 }); + await expect(raw(client, 'skills/list', { cursor: 'garbage' })).rejects.toMatchObject({ code: -32_602 }); + }); + + it('lets an app resource registered first win at its URI (documented: do not do this)', async () => { + const client = await connect(revision, mcp => { + mcp.registerResource('override', REVIEW, { mimeType: 'text/plain' }, () => ({ contents: [{ uri: REVIEW, text: 'app wins' }] })); + catalog.attach(mcp); + }); + const { contents } = await raw(client, 'resources/read', { uri: REVIEW }); + expect(contents).toEqual([{ uri: REVIEW, text: 'app wins' }]); + const { resources } = await raw(client, 'resources/list'); + expect((resources as Array<{ uri: string }>).map(resource => resource.uri)).toEqual([REVIEW, GIT]); + }); + + it('lists nothing in resources/list for a source without listResources, and no directoryRead', async () => { + const source = new SkillsExtension({ source: await notesSource(false) }); + const client = await connect(revision, mcp => source.attach(mcp)); + expect(client.getServerCapabilities()?.extensions).toEqual({ [EXTENSION]: {} }); + const { resources } = await raw(client, 'resources/list'); + expect(resources).toEqual([]); + await expect(raw(client, 'resources/directory/read', { uri: 'skill://notes' })).rejects.toMatchObject({ code: -32_601 }); + }); + + it('lists what listResources returns, and the host verifies source-built skills', async () => { + const source = new SkillsExtension({ source: await notesSource(true) }); + const client = await connect(revision, mcp => source.attach(mcp)); + const { resources } = await raw(client, 'resources/list'); + expect(resources).toEqual([{ uri: 'skill://notes/SKILL.md', name: 'notes', mimeType: 'text/markdown' }]); + const skills = new SkillsClient(client); + const { + skills: [notes] + } = await skills.list(); + expect(await skills.read(notes!)).toMatchObject({ verified: true, frontmatter: { name: 'notes' } }); + }); + + it('serves a source under another scheme with baseUri', async () => { + const notes = await skillEntry('notes', { 'SKILL.md': NOTES_MD }, { baseUri: 'github://owner/repo/skills' }); + const source = new SkillsExtension({ + source: { + list: async () => ({ skills: [notes] }), + get: async uri => (uri === notes.uri ? notes : undefined), + read: async uri => (uri === notes.uri ? { text: NOTES_MD } : undefined) + }, + baseUri: 'github://owner/repo/skills' + }); + const client = await connect(revision, mcp => source.attach(mcp)); + const { resourceTemplates } = await raw(client, 'resources/templates/list'); + expect(resourceTemplates).toEqual([{ name: EXTENSION, uriTemplate: 'github://owner/repo/skills/{+path}' }]); + expect(await new SkillsClient(client).read(notes)).toMatchObject({ + uri: 'github://owner/repo/skills/notes/SKILL.md', + verified: true + }); + }); +}); diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json index 02b5a5f54e..8a7f23b58a 100644 --- a/test/integration/tsconfig.json +++ b/test/integration/tsconfig.json @@ -31,6 +31,9 @@ "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/server/validators/ajv": ["./node_modules/@modelcontextprotocol/server/src/validators/ajv.ts"], "@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"], + "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills/node": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/node.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], From 05c82ce898f1e835436d23d26e5d5b37bd37df67 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 08:05:08 +0300 Subject: [PATCH 24/45] test(conformance): run SEP-2640 scenarios against the SDK API Bumps @modelcontextprotocol/conformance to 0.2.0-alpha.12. The everything server serves a SkillsExtension catalog (built from #2797's fixture idea with the SDK's own API); the everything client loads skills through SkillsClient for the verify-digest, verify-size, verify-frontmatter and no-prefetch scenarios. Co-authored-by: tobi-oye --- pnpm-lock.yaml | 10 ++-- .../expected-failures.2026-07-28.yaml | 7 ++- test/conformance/expected-failures.yaml | 10 +++- test/conformance/package.json | 2 +- test/conformance/src/everythingClient.ts | 49 +++++++++++++++++++ test/conformance/src/everythingServer.ts | 5 ++ test/conformance/src/skills.ts | 19 +++++++ test/conformance/tsconfig.json | 2 + 8 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 test/conformance/src/skills.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8702c141e..f5a1fcdea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1949,8 +1949,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.11 - version: 0.2.0-alpha.11(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.12 + version: 0.2.0-alpha.12(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../../packages/core-internal @@ -3243,8 +3243,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.11': - resolution: {integrity: sha512-imPK9tx5gQsL6ZKQq4MrsyDYfSaIwpRmX6+ogjbeAXs9LGvxkBxWcY7KcS7TvwaBk/ZiVWl6b/naF4q83UwDRA==} + '@modelcontextprotocol/conformance@0.2.0-alpha.12': + resolution: {integrity: sha512-eXtZFgmtZiU5SsvXty+RGXGnyIqEVm3GLU/HZmg1wMjtKOocPZZgD9HQKd30oJhio5UB0wFJmsc6r8u48Q+EYA==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -7865,7 +7865,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.11(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.12(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@octokit/rest': 22.0.1 diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 0f7090a82c..b1be5a739e 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -12,10 +12,15 @@ # 2025 legs. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.11). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.12). Newer conformance releases # are adopted by deliberately bumping the pin and reconciling this file in the # same change. # +# alpha.11 -> alpha.12 reconciliation: the new SEP-2640 skills scenarios are +# extension scenarios, which --spec-version never selects, so these legs do not +# run them (forced to 2026-07-28 with --force, all seven pass). Both sections +# stay empty. +# # alpha.10 -> alpha.11 reconciliation: `json-schema-2020-12-preservation` # (client leg; everythingClient negotiates via server/discover like tools_call) # passes at 2026-07-28 — the referee reports it as added-after-release, unscored diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 99045c1023..9440de1782 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,10 +2,18 @@ # CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.11). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.12). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling # this file in the same change. # +# alpha.11 -> alpha.12 reconciliation: the referee added the SEP-2640 +# (io.modelcontextprotocol/skills) extension scenarios, all passing, so none is +# baselined — server `sep-2640-skills-enumeration` / `-manifest` / `-directory` +# (against the SkillsExtension catalog in src/skills.ts) and client +# `sep-2640-client-verify-digest` / `-verify-size` / `-verify-frontmatter` / +# `-no-prefetch` (driven through SkillsClient by everythingClient.ts). No other +# scenario changed outcome. +# # alpha.10 -> alpha.11 reconciliation: the referee added two scenarios, both # passing, so neither is baselined — `json-schema-2020-12-preservation` # (client; SEP-1613/SEP-2106 keyword round-trip, driven by everythingClient.ts) diff --git a/test/conformance/package.json b/test/conformance/package.json index 293180e6e3..cd7f207fe5 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -39,7 +39,7 @@ "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.11", + "@modelcontextprotocol/conformance": "0.2.0-alpha.12", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 450f1676b3..2c9849cf0a 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -20,6 +20,7 @@ import { requestJwtAuthorizationGrant, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient, SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; import * as z from 'zod/v4'; import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider'; @@ -904,6 +905,54 @@ async function runJsonSchema2020_12PreservationClient(serverUrl: string): Promis registerScenario('json-schema-2020-12-preservation', runJsonSchema2020_12PreservationClient); +// ============================================================================ +// SEP-2640 Skills +// ============================================================================ + +async function connectSkillsHost(serverUrl: string): Promise { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {}, ...(isModernConformanceRun() && { versionNegotiation: { mode: 'auto' as const } }) } + ); + await client.connect(new StreamableHTTPClientTransport(new URL(serverUrl))); + return client; +} + +// Load every listed skill the way a host does: read SKILL.md (verified by +// SkillsClient), and only if it verifies, read the skill's other files. +async function runSkillsLoadClient(serverUrl: string): Promise { + const client = await connectSkillsHost(serverUrl); + const skills = new SkillsClient(client); + const { skills: entries } = await skills.list(); + for (const entry of entries) { + try { + await skills.read(entry); + } catch (error) { + if (!(error instanceof SkillVerificationError)) throw error; + logger.debug(`not loading ${entry.uri}: ${error.message}`); + continue; + } + for (const resource of entry.resources === 'dynamic' ? [] : entry.resources) { + if (resource.uri !== entry.uri) await skills.read(entry, resource.uri); + } + } + await client.close(); +} + +// Listing alone must not read any skill file. +async function runSkillsNoPrefetchClient(serverUrl: string): Promise { + const client = await connectSkillsHost(serverUrl); + const { skills } = await new SkillsClient(client).list(); + logger.debug(`listed ${skills.length} skills without reading them`); + await client.close(); +} + +registerScenarios( + ['sep-2640-client-verify-digest', 'sep-2640-client-verify-size', 'sep-2640-client-verify-frontmatter'], + runSkillsLoadClient +); +registerScenario('sep-2640-client-no-prefetch', runSkillsNoPrefetchClient); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 6ede22b2bd..2aa3d31601 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -43,6 +43,8 @@ import type { Request, Response } from 'express'; import express from 'express'; import * as z from 'zod/v4'; +import { conformanceSkills } from './skills'; + // Server state const resourceSubscriptions = new Set(); const watchedResourceContent = 'Watched resource content'; @@ -167,6 +169,9 @@ function createMcpServer() { } ); + // SEP-2640: skills/list, skills/get, resources/directory/read and the skill:// resource template. + conformanceSkills.attach(mcpServer); + // Helper to send log messages using the underlying server function sendLog( level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency', diff --git a/test/conformance/src/skills.ts b/test/conformance/src/skills.ts new file mode 100644 index 0000000000..7e1dde4b4a --- /dev/null +++ b/test/conformance/src/skills.ts @@ -0,0 +1,19 @@ +/** + * Skills (SEP-2640) served by the conformance server, built with the SDK's + * own API. Started as typescript-sdk#2797 (tobi-oye). `pageSize: 1` makes + * skills/list and resources/directory/read page, so the pagination checks run. + */ +import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; + +export const conformanceSkills = await SkillsExtension.load( + memorySkillFiles({ + 'pdf-processing/SKILL.md': + '---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents.\nmetadata:\n author: mcp-conformance\n---\n' + + '# PDF processing\n\nRead references/FORMS.md before filling a form.\n', + 'pdf-processing/references/FORMS.md': '# Forms\n\nFill the fields from top to bottom.\n', + 'pdf-processing/scripts/extract.py': 'print("extract")\n', + 'acme/billing/refunds/SKILL.md': '---\nname: refunds\ndescription: Process customer refunds.\n---\n# Refunds\n', + 'acme/billing/refunds/templates/refund-email.md': 'Your refund is on its way.\n' + }), + { pageSize: 1, strict: true } +); diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index 7e6efcdfba..676fe7e1c5 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -19,6 +19,8 @@ "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/index.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], From 175b8c427a2d52d602c44efaa38a8494e03635e4 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 08:14:00 +0300 Subject: [PATCH 25/45] test(conformance): run the skills scenarios on both protocol versions Without --spec-version the conformance runner runs extension scenarios at 2026-07-28 for servers and 2025-11-25 for clients. Two forced legs cover the other version on each side, and CI runs them. --- .github/workflows/conformance.yml | 2 ++ test/conformance/expected-failures.2026-07-28.yaml | 11 ++++++++--- test/conformance/expected-failures.yaml | 10 ++++++++++ test/conformance/package.json | 2 ++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index bb2dbef504..989595cbf5 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -30,6 +30,7 @@ jobs: - run: pnpm install - run: pnpm run build:all - run: pnpm run test:conformance:client:all + - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:skills:2026 - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:2026 server-conformance: @@ -50,4 +51,5 @@ jobs: - run: pnpm run test:conformance:server - run: pnpm run test:conformance:server:draft - run: pnpm run test:conformance:server:extensions + - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:skills:2025 - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:2026 diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index b1be5a739e..1eb2d8fdb0 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -17,9 +17,14 @@ # same change. # # alpha.11 -> alpha.12 reconciliation: the new SEP-2640 skills scenarios are -# extension scenarios, which --spec-version never selects, so these legs do not -# run them (forced to 2026-07-28 with --force, all seven pass). Both sections -# stay empty. +# extension scenarios, and --spec-version drops extension scenarios from a +# suite, so the two legs that read this file never run them. Their 2026-07-28 +# coverage comes from elsewhere. The server runner already runs extension +# scenarios at 2026-07-28 by default, in `test:conformance:server:extensions` +# (expected-failures.yaml). The client runner defaults them to 2025-11-25, so +# `test:conformance:client:skills:2026` runs each one with +# `--scenario --spec-version 2026-07-28 --force` (no baseline). Both +# sections stay empty. # # alpha.10 -> alpha.11 reconciliation: `json-schema-2020-12-preservation` # (client leg; everythingClient negotiates via server/discover like tools_call) diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 9440de1782..bedba50604 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -14,6 +14,16 @@ # `-no-prefetch` (driven through SkillsClient by everythingClient.ts). No other # scenario changed outcome. # +# Extension scenarios sit outside the spec timeline, so each runner picks their +# protocol version: without --spec-version the server runner runs them at +# 2026-07-28 and the client runner at 2025-11-25. The `--suite all` legs that +# read this file therefore cover SEP-2640 server@2026-07-28 and +# client@2025-11-25. The other version on each side runs in +# `test:conformance:server:skills:2025` and `test:conformance:client:skills:2026` +# (one `--scenario --spec-version --force` run per scenario, no +# baseline, so any failure fails the step): --spec-version drops extension +# scenarios from a suite, and --force only applies to --scenario. +# # alpha.10 -> alpha.11 reconciliation: the referee added two scenarios, both # passing, so neither is baselined — `json-schema-2020-12-preservation` # (client; SEP-1613/SEP-2106 keyword round-trip, driven by everythingClient.ts) diff --git a/test/conformance/package.json b/test/conformance/package.json index cd7f207fe5..7c237045d9 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -29,12 +29,14 @@ "test:conformance:client": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite core --expected-failures ./expected-failures.yaml", "test:conformance:client:all": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --expected-failures ./expected-failures.yaml", "test:conformance:client:2026": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml", + "test:conformance:client:skills:2026": "for s in sep-2640-client-verify-digest sep-2640-client-verify-size sep-2640-client-verify-frontmatter sep-2640-client-no-prefetch; do conformance client --command 'node --import tsx ./src/everythingClient.ts' --scenario $s --spec-version 2026-07-28 --force || exit 1; done", "test:conformance:client:run": "node --import tsx ./src/everythingClient.ts", "test:conformance:server": "scripts/run-server-conformance.sh --expected-failures ./expected-failures.yaml", "test:conformance:server:draft": "scripts/run-server-conformance.sh --suite draft --expected-failures ./expected-failures.yaml", "test:conformance:server:extensions": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml", "test:conformance:server:all": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml", "test:conformance:server:2026": "scripts/run-server-conformance.sh --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml", + "test:conformance:server:skills:2025": "for s in sep-2640-skills-enumeration sep-2640-skills-manifest sep-2640-skills-directory; do scripts/run-server-conformance.sh --scenario $s --spec-version 2025-11-25 --force || exit 1; done", "test:conformance:server:run": "node --import tsx ./src/everythingServer.ts", "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, From c38113fd33883038c075d96732b0c7352617201e Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 08:22:30 +0300 Subject: [PATCH 26/45] docs(skills): examples/skills story The server serves the skills/ folder next to it with loadSkills (prefix "examples"); the client lists the catalog, loads git-workflow with verified reads, follows a reference, browses the refunds folder and catches a verification failure, over stdio and HTTP on both protocol eras. --- examples/README.md | 1 + examples/skills/README.md | 16 +++++ examples/skills/client.ts | 68 +++++++++++++++++++ examples/skills/package.json | 23 +++++++ examples/skills/server.ts | 39 +++++++++++ .../skills/acme/billing/refunds/SKILL.md | 11 +++ .../acme/billing/refunds/escalation/SKILL.md | 8 +++ .../billing/refunds/templates/refund-email.md | 3 + examples/skills/skills/git-workflow/SKILL.md | 10 +++ .../skills/git-workflow/references/REVIEW.md | 4 ++ examples/tsconfig.json | 3 + pnpm-lock.yaml | 22 ++++++ 12 files changed, 208 insertions(+) create mode 100644 examples/skills/README.md create mode 100644 examples/skills/client.ts create mode 100644 examples/skills/package.json create mode 100644 examples/skills/server.ts create mode 100644 examples/skills/skills/acme/billing/refunds/SKILL.md create mode 100644 examples/skills/skills/acme/billing/refunds/escalation/SKILL.md create mode 100644 examples/skills/skills/acme/billing/refunds/templates/refund-email.md create mode 100644 examples/skills/skills/git-workflow/SKILL.md create mode 100644 examples/skills/skills/git-workflow/references/REVIEW.md diff --git a/examples/README.md b/examples/README.md index fc57f2de7d..51bc960a13 100644 --- a/examples/README.md +++ b/examples/README.md @@ -45,6 +45,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] | [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern | | [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | | [`extension-capabilities/`](./extension-capabilities/README.md) | Declaring `capabilities.extensions` and reading the negotiated map | stdio + http | dual | +| [`skills/`](./skills/README.md) | Skills over MCP (SEP-2640): `loadSkills` a folder, `SkillsClient` lists, loads and verifies | stdio + http | dual | | [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | | [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | | [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | diff --git a/examples/skills/README.md b/examples/skills/README.md new file mode 100644 index 0000000000..90764a41e5 --- /dev/null +++ b/examples/skills/README.md @@ -0,0 +1,16 @@ +# skills + +The server serves the `skills/` folder as MCP skills (SEP-2640) with one +`loadSkills` call, under the `examples` prefix. The client lists the catalog, +loads `git-workflow` (its `SKILL.md`, verified for size, digest and +frontmatter), follows the reference to `references/REVIEW.md`, browses the +`refunds` folder, and catches a verification failure. + +```bash +pnpm --filter @mcp-examples/skills client # modern leg over stdio +pnpm --filter @mcp-examples/skills client -- --legacy +pnpm --filter @mcp-examples/skills run server -- --http --port 3000 +pnpm --filter @mcp-examples/skills client -- --http http://127.0.0.1:3000/mcp +``` + +See [Serving skills](../../docs/servers/skills.md) and [Reading skills](../../docs/clients/skills.md). diff --git a/examples/skills/client.ts b/examples/skills/client.ts new file mode 100644 index 0000000000..63643ce047 --- /dev/null +++ b/examples/skills/client.ts @@ -0,0 +1,68 @@ +/** + * Connects to `./server.ts` and walks the host side of MCP skills: list the + * catalog, load a skill (its SKILL.md, verified), follow a reference, browse + * a skill folder, and catch a verification failure. + * + * Spawns the sibling `server.ts` over stdio by default, or connects to a + * running endpoint under `--http `. See `examples/CONTRIBUTING.md` for + * the canonical shape. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient, SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'skills-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await client.connect( + transport === 'stdio' + ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) + : new StreamableHTTPClientTransport(new URL(url)) +); + +const skills = new SkillsClient(client); +check.ok(skills.supported); + +// 1. List: entries only (frontmatter, file list, digests). No skill file is read yet. +const { skills: entries, invalid } = await skills.list(); +console.log(`[client] ${era} leg: ${entries.map(entry => entry.frontmatter.name).join(', ')}`); +check.deepEqual(invalid, []); +check.deepEqual( + entries.map(entry => entry.uri), + [ + 'skill://examples/acme/billing/refunds/SKILL.md', + 'skill://examples/acme/billing/refunds/escalation/SKILL.md', + 'skill://examples/git-workflow/SKILL.md' + ] +); + +// 2. Load a skill: SKILL.md is checked for size, digest and frontmatter before you use it. +const git = await skills.get('skill://examples/git-workflow/SKILL.md'); +const manifest = await skills.read(git); +check.equal(manifest.verified, true); +check.equal(manifest.frontmatter?.['name'], 'git-workflow'); +check.match(manifest.body ?? '', /review checklist/); + +// 3. Follow a reference: the relative path resolves against the skill folder and is verified too. +const review = await skills.read(git, 'references/REVIEW.md'); +check.match(review.text ?? '', /# Review checklist/); + +// 4. Browse a skill folder (the server declares directoryRead). +check.ok(skills.directoryRead); +const children = await skills.readDirectory('skill://examples/acme/billing/refunds'); +check.deepEqual( + children.map(child => child.name), + ['SKILL.md', 'escalation', 'templates'] +); + +// 5. A failed check is an error, never content: here, an entry whose frontmatter was altered. +const altered = { ...git, frontmatter: { ...git.frontmatter, description: 'Something else.' } }; +await check.rejects(skills.read(altered), (error: unknown) => error instanceof SkillVerificationError && error.reason === 'frontmatter'); + +console.log(`[client] ${era} leg: loaded ${git.frontmatter.name}, followed 1 reference, verification failure caught`); +await client.close(); diff --git a/examples/skills/package.json b/examples/skills/package.json new file mode 100644 index 0000000000..8950b18b42 --- /dev/null +++ b/examples/skills/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/skills", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "Both legs list, load and verify skills, follow a reference and browse a skill folder." + } +} diff --git a/examples/skills/server.ts b/examples/skills/server.ts new file mode 100644 index 0000000000..91d330f214 --- /dev/null +++ b/examples/skills/server.ts @@ -0,0 +1,39 @@ +/** + * Serves the `skills/` folder next to this file as MCP skills (SEP-2640), + * under the `examples` prefix: one `loadSkills` call at startup, attached to + * every server instance. + * + * One binary, either transport — selected by `--http --port ` (defaults to + * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. + */ +import { serve } from '@hono/node-server'; +import { parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +// Load once: the catalog keeps metadata only and serves every server below. +const skills = await loadSkills(siblingPath(import.meta.url, 'skills'), { prefix: 'examples' }); + +function buildServer(): McpServer { + const mcp = new McpServer({ name: 'skills-example', version: '1.0.0' }); + skills.attach(mcp); + return mcp; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + // `createMcpHonoApp()` arms localhost host/origin validation by default; + // bind loopback explicitly to match. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/skills/skills/acme/billing/refunds/SKILL.md b/examples/skills/skills/acme/billing/refunds/SKILL.md new file mode 100644 index 0000000000..65d8c877b3 --- /dev/null +++ b/examples/skills/skills/acme/billing/refunds/SKILL.md @@ -0,0 +1,11 @@ +--- +name: refunds +description: Process a customer refund end to end. +metadata: + owner: billing +--- + +# Refunds + +Check the order, issue the refund, then send [the refund email](templates/refund-email.md). +For disputed orders, use the escalation skill. diff --git a/examples/skills/skills/acme/billing/refunds/escalation/SKILL.md b/examples/skills/skills/acme/billing/refunds/escalation/SKILL.md new file mode 100644 index 0000000000..16eed5361b --- /dev/null +++ b/examples/skills/skills/acme/billing/refunds/escalation/SKILL.md @@ -0,0 +1,8 @@ +--- +name: escalation +description: Escalate a disputed refund to the billing lead. +--- + +# Escalation + +Collect the order number and the customer's reason, then page the billing lead. diff --git a/examples/skills/skills/acme/billing/refunds/templates/refund-email.md b/examples/skills/skills/acme/billing/refunds/templates/refund-email.md new file mode 100644 index 0000000000..c34ecfeefa --- /dev/null +++ b/examples/skills/skills/acme/billing/refunds/templates/refund-email.md @@ -0,0 +1,3 @@ +Hello {{customer}}, + +Your refund of {{amount}} is on its way. diff --git a/examples/skills/skills/git-workflow/SKILL.md b/examples/skills/skills/git-workflow/SKILL.md new file mode 100644 index 0000000000..abbe9dfe82 --- /dev/null +++ b/examples/skills/skills/git-workflow/SKILL.md @@ -0,0 +1,10 @@ +--- +name: git-workflow +description: Branching, commit and review conventions for this team. +--- + +# Git workflow + +1. Branch from `main` as `feature/`. +2. Keep commits small; write messages in the imperative. +3. Before opening a pull request, go through [the review checklist](references/REVIEW.md). diff --git a/examples/skills/skills/git-workflow/references/REVIEW.md b/examples/skills/skills/git-workflow/references/REVIEW.md new file mode 100644 index 0000000000..ab65e3edf0 --- /dev/null +++ b/examples/skills/skills/git-workflow/references/REVIEW.md @@ -0,0 +1,4 @@ +# Review checklist + +- Tests pass locally. +- A changeset describes the change. diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 40da4390c3..8cdaf6e048 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -15,6 +15,9 @@ "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/skills/node": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/node.ts"], "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/fastify": ["./node_modules/@modelcontextprotocol/fastify/src/index.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5a1fcdea5..5369810c7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1088,6 +1088,28 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + examples/skills: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/hono': + specifier: workspace:* + version: link:../../packages/middleware/hono + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/sse-polling: dependencies: '@mcp-examples/shared': From d5f924de54cb9823f0a33e21804c672a7b870b23 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 08:41:46 +0300 Subject: [PATCH 27/45] docs(skills): guides and changesets docs/servers/skills.md and docs/clients/skills.md are synced from executed companions under examples/guides. The skills changeset credits every author explicitly and replaces #2818's; a separate patch changeset covers the client's 2026-07-28 method-not-found handling, and the protocol-versions era table now lists HTTP 400/404. --- .../client-modern-404-method-not-found.md | 5 + .changeset/ext-skills-phase-1.md | 15 -- .changeset/skills-extension.md | 18 ++ docs/.vitepress/nav.ts | 4 +- docs/clients/skills.md | 134 ++++++++++++++ docs/protocol-versions.md | 26 +-- docs/servers/skills.md | 172 ++++++++++++++++++ examples/guides/clients/skills.examples.ts | 78 ++++++++ examples/guides/servers/skills.examples.ts | 108 +++++++++++ .../skills/acme/billing/refunds/SKILL.md | 6 + .../servers/skills/git-workflow/SKILL.md | 8 + .../skills/git-workflow/references/REVIEW.md | 3 + 12 files changed, 548 insertions(+), 29 deletions(-) create mode 100644 .changeset/client-modern-404-method-not-found.md delete mode 100644 .changeset/ext-skills-phase-1.md create mode 100644 .changeset/skills-extension.md create mode 100644 docs/clients/skills.md create mode 100644 docs/servers/skills.md create mode 100644 examples/guides/clients/skills.examples.ts create mode 100644 examples/guides/servers/skills.examples.ts create mode 100644 examples/guides/servers/skills/acme/billing/refunds/SKILL.md create mode 100644 examples/guides/servers/skills/git-workflow/SKILL.md create mode 100644 examples/guides/servers/skills/git-workflow/references/REVIEW.md diff --git a/.changeset/client-modern-404-method-not-found.md b/.changeset/client-modern-404-method-not-found.md new file mode 100644 index 0000000000..f8d121080c --- /dev/null +++ b/.changeset/client-modern-404-method-not-found.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +`StreamableHTTPClientTransport`: on a 2026-07-28 connection, an HTTP `404` whose body is a JSON-RPC `-32601` error (the server does not implement the method) now rejects the request with `ProtocolError(-32601)` instead of `SdkHttpError(404)`, as a `400` with a JSON-RPC error body already did. 2025-era connections are unchanged. diff --git a/.changeset/ext-skills-phase-1.md b/.changeset/ext-skills-phase-1.md deleted file mode 100644 index 695c467f79..0000000000 --- a/.changeset/ext-skills-phase-1.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@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/.changeset/skills-extension.md b/.changeset/skills-extension.md new file mode 100644 index 0000000000..ace6ce66e5 --- /dev/null +++ b/.changeset/skills-extension.md @@ -0,0 +1,18 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +author: @KKonstantinov +author: @tobi-oye +author: @mattzcarey + +Add the Skills extension (SEP-2640), reachable through new `ext/skills` subpaths; package root exports are unchanged. + +- `@modelcontextprotocol/server/ext/skills`: `SkillsExtension` (`attach(server)` serves `skills/list`, `skills/get`, `resources/directory/read` and a `skill://{+path}` resource template), `SkillsExtension.load(files)` returning a `SkillCatalog` with `entries` and `reload()`, the `SkillFiles` storage adapter with `memorySkillFiles`, `SkillSource` for generated catalogs, `skillEntry`, and `SkillCatalogError`. Invalid skills are skipped with a warning (`onWarning`), or fail under `strict`. A file whose bytes are valid UTF-8 is served as `text`, whatever its MIME type; any other file as a base64 `blob`. +- `@modelcontextprotocol/server/ext/skills/node`: `loadSkills(folder)` and `nodeSkillFiles(folder)`. +- `@modelcontextprotocol/client/ext/skills`: `SkillsClient` with `list()`, `get()`, `read(skill, path?)` (size, digest and `SKILL.md` frontmatter verified; a failed check is fetched once more with `cacheMode: 'refresh'`, then `SkillVerificationError` on a mismatch) and `readDirectory()`. +- `@modelcontextprotocol/core/ext/skills`: schemas, types and constants, including `SKILL_LIMITS`. + +Works on 2025-11-25 and 2026-07-28; caching fields (`ttlMs`, `cacheScope`) are sent on 2026-07-28 only. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 02ab4d2ff6..9c07c8c0ed 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -21,6 +21,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ items: [ { text: 'Tools', link: '/servers/tools' }, { text: 'Resources', link: '/servers/resources' }, + { text: 'Skills', link: '/servers/skills' }, { text: 'Prompts', link: '/servers/prompts' }, { text: 'Completion', link: '/servers/completion' }, { text: 'Logging, progress, cancellation', link: '/servers/logging-progress-cancellation' }, @@ -56,7 +57,8 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'OAuth', link: '/clients/oauth' }, { text: 'Machine auth', link: '/clients/machine-auth' }, { text: 'Middleware', link: '/clients/middleware' }, - { text: 'Caching', link: '/clients/caching' } + { text: 'Caching', link: '/clients/caching' }, + { text: 'Skills', link: '/clients/skills' } ] }, { text: 'Protocol versions', link: '/protocol-versions' }, diff --git a/docs/clients/skills.md b/docs/clients/skills.md new file mode 100644 index 0000000000..639700addf --- /dev/null +++ b/docs/clients/skills.md @@ -0,0 +1,134 @@ +--- +shape: how-to +--- + +# Skills + +`SkillsClient` lists a server's skills and reads their files, checking each file against what the server published. + +```ts source="../../examples/guides/clients/skills.examples.ts#SkillsClient_list" +import { SkillsClient, SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; + +const skills = new SkillsClient(client); +const { skills: entries } = await skills.list(); +console.log(entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter.description}`).join('\n')); +``` + +Against a server with two skills, it prints each skill's name and description: + +```text +refunds: Process customer refunds. +git-workflow: Branching and review conventions. +``` + +Listing reads no skill file: each **entry** carries the skill's frontmatter and its file list with digests, enough to decide which skill to load. + +## Load a skill + +Read the entry's `SKILL.md`. + +```ts source="../../examples/guides/clients/skills.examples.ts#read_manifest" +const git = entries.find(entry => entry.frontmatter.name === 'git-workflow')!; +const manifest = await skills.read(git); +console.log(manifest.verified, manifest.frontmatter); +``` + +The file passes every check, and its frontmatter matches the entry: + +```text +true { + name: 'git-workflow', + description: 'Branching and review conventions.' +} +``` + +`read(entry)` checks the size and SHA-256 digest against the entry, then compares the frontmatter field by field. `manifest.body` holds the instructions to give the model. + +## Read the files a skill references + +Pass the path `SKILL.md` uses. + +```ts source="../../examples/guides/clients/skills.examples.ts#read_reference" +const review = await skills.read(git, 'references/REVIEW.md'); +console.log(review.text); +``` + +`review.text` holds the verified file: + +```text +# Review checklist + +- Tests pass. + +``` + +A relative path resolves against the skill's folder, as a link in `SKILL.md` does, and `read` ignores a fragment. It matches the path to the entry's file list however either side percent-encodes the name, so `references/Q&A.md` works. `bytes` always holds the file's bytes; `text` is set when the server sent the file as text. + +## Handle verification failures + +Catch `SkillVerificationError`. + +```ts source="../../examples/guides/clients/skills.examples.ts#SkillVerificationError" +try { + await skills.read(git, 'references/MISSING.md'); +} catch (error) { + if (!(error instanceof SkillVerificationError)) throw error; + console.log(error.reason, error.uri); +} +``` + +The entry does not list `references/MISSING.md`, so `read` refuses it before sending any request: + +```text +unlisted skill://git-workflow/references/MISSING.md +``` + +`reason` is `unlisted`, `size`, `digest` or `frontmatter`; a path that does not parse is `unlisted` too. When a size, digest or frontmatter check fails, `read` fetches the file once more with `cacheMode: 'refresh'` and throws only if the fresh copy fails as well. On any reason, do not use the file; for `SKILL.md`, do not load the skill. + +## Browse a skill folder + +List a folder's direct children when the server declares `directoryRead`. + +```ts source="../../examples/guides/clients/skills.examples.ts#readDirectory" +if (skills.directoryRead) { + const children = await skills.readDirectory('skill://acme/billing/refunds'); + console.log(children.map(child => `${child.name} ${child.mimeType}`).join('\n')); +} +``` + +The `refunds` folder holds its `SKILL.md` and a `templates` folder: + +```text +SKILL.md text/markdown +templates inode/directory +``` + +## Get a skill by URI + +Fetch one entry, listed or not. + +```ts source="../../examples/guides/clients/skills.examples.ts#get" +const refunds = await skills.get('skill://acme/billing/refunds/SKILL.md'); +console.log(refunds.frontmatter.name); +``` + +It prints the skill's name from the entry's frontmatter: + +```text +refunds +``` + +::: info Dynamic skills +An entry whose `resources` is `"dynamic"` has no file list. `read` then skips the size and digest checks and returns `verified: false`, but still compares the `SKILL.md` frontmatter. +::: + +::: warning Host responsibilities +Accept skills up to `SKILL_LIMITS` (512 files, 16 MiB), get approval before you load a skill from a server, and record which server each skill came from. Read a skill's files when you load it, never while listing. +::: + +## Recap + +- `new SkillsClient(client)` wraps a connected client; `list()` returns entries without reading files. +- `read(entry)` verifies `SKILL.md` (size, digest, frontmatter) and returns its frontmatter and body. +- `read(entry, path)` resolves a path against the skill's folder and verifies that file. +- A failed check throws `SkillVerificationError` with a `reason`. diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 02f65efd60..e098203ed5 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -161,19 +161,19 @@ On stdio, `serveStdio(factory)` from `@modelcontextprotocol/server/stdio` is the This table is the only copy of the era differences in these docs. `getProtocolEra()` on the client and the factory's `era` on the server tell you which column you are in. -| Axis | 2025 era (`'legacy'`, `2024-10-07` … `2025-11-25`) | 2026 era (`'modern'`, `2026-07-28`) | -| ------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------ | -| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | -| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | -| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | -| Client identity on the server | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | -| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` | `return inputRequired(...)` from the handler | -| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | -| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | -| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `logLevel` `_meta` envelope key (absent = no logs) | -| HTTP `400` with a JSON-RPC error body | `SdkHttpError` | `ProtocolError`, delivered in-band | -| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | -| Liveness check | `client.ping()` | not defined — outbound call rejects per the era-mismatch row | +| Axis | 2025 era (`'legacy'`, `2024-10-07` … `2025-11-25`) | 2026 era (`'modern'`, `2026-07-28`) | +| ------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | +| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | +| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | +| Client identity on the server | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | +| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` | `return inputRequired(...)` from the handler | +| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | +| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | +| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `logLevel` `_meta` envelope key (absent = no logs) | +| HTTP `400`/`404` with a JSON-RPC error body | `SdkHttpError` | `ProtocolError`, delivered in-band | +| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | +| Liveness check | `client.ping()` | not defined — outbound call rejects per the era-mismatch row | ## Separate deprecation from era diff --git a/docs/servers/skills.md b/docs/servers/skills.md new file mode 100644 index 0000000000..7bed3fb922 --- /dev/null +++ b/docs/servers/skills.md @@ -0,0 +1,172 @@ +--- +shape: how-to +--- + +# Skills + +`loadSkills` reads a folder of skills once; `attach` serves it on a server. + +```ts source="../../examples/guides/servers/skills.examples.ts#loadSkills_basic" +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; + +const skills = await loadSkills('./skills'); + +const server = new McpServer({ name: 'team-skills', version: '1.0.0' }); +skills.attach(server); +``` + +A **skill** is a folder with a `SKILL.md` and the files it references (SEP-2640). Clients list skills with `skills/list` and read each file with `resources/read`, checking it against the digest your server published. + +## Lay out the skills folder + +Each folder that contains a `SKILL.md` is one skill; other folders only group skills in the URI. + +```text +skills/ +├── git-workflow/ +│ ├── SKILL.md +│ └── references/REVIEW.md +└── acme/billing/refunds/ + └── SKILL.md +``` + +`SKILL.md` starts with YAML frontmatter holding `name` (the same as the skill's folder name) and `description`. Print the URIs the catalog publishes: + +```ts source="../../examples/guides/servers/skills.examples.ts#entries" +console.log(skills.entries.map(skill => skill.uri).join('\n')); +``` + +It prints one `SKILL.md` URI per skill, sorted: + +```text +skill://acme/billing/refunds/SKILL.md +skill://git-workflow/SKILL.md +``` + +## Serve one catalog from every server + +Attach the same catalog to each server your handler creates. + +```ts source="../../examples/guides/servers/skills.examples.ts#attach_perRequest" +const handler = createMcpHandler(() => { + const mcp = new McpServer({ name: 'team-skills', version: '1.0.0' }); + skills.attach(mcp); + return mcp; +}); +``` + +The catalog keeps metadata only (paths, digests, sizes, frontmatter); each read goes to the folder, and the SDK serves the bytes only if they still match the published digest. It sends a file as `text` whenever its bytes are valid UTF-8 (a BOM included), whatever its MIME type, and as a base64 `blob` otherwise. + +## Pick up changes with `reload()` + +Rescan the folder and swap the new catalog in at once. + +```ts source="../../examples/guides/servers/skills.examples.ts#reload" +const changes = await skills.reload(); +console.log(changes); +``` + +With nothing changed on disk, `reload()` reports no changes: + +```text +{ added: [], removed: [], changed: [] } +``` + +Until you call `reload()`, the server answers a read of a file edited on disk with an error instead of unverified content. `reload()` sends connected clients `notifications/resources/list_changed` when a skill appears, disappears, or changes its name or description. If the rescan fails, `reload()` rejects and the old catalog keeps serving. + +## Serve skills defined in code + +Pass the files to `memorySkillFiles` where there is no file system, such as Workers or tests. + +```ts source="../../examples/guides/servers/skills.examples.ts#memorySkillFiles" +import type { SkillFiles, SkillSource } from '@modelcontextprotocol/server/ext/skills'; +import { memorySkillFiles, skillEntry, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; + +const RELEASE_NOTES_MD = '---\nname: release-notes\ndescription: Write release notes from merged pull requests.\n---\n# Release notes\n'; + +const inline = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': RELEASE_NOTES_MD })); +``` + +`memorySkillFiles` re-reads the object you pass whenever a walk starts (`list('')`), so `reload()` sees files you add or change later. + +## Serve skills from your own storage + +Implement `SkillFiles`, two functions over relative paths, to load from any store. + +```ts source="../../examples/guides/servers/skills.examples.ts#skillFiles_custom" +// Two operations over '/'-separated paths, '' being the root: here, a key-value store of objects. +function bucketSkillFiles(bucket: ReadonlyMap): SkillFiles { + return { + async list(dir) { + const prefix = dir === '' ? '' : `${dir}/`; + const children = new Map(); + for (const key of bucket.keys()) { + if (!key.startsWith(prefix)) continue; + const [name, ...rest] = key.slice(prefix.length).split('/'); + if (name !== undefined) children.set(name, rest.length > 0 ? 'directory' : 'file'); + } + return [...children].map(([name, kind]) => ({ name, kind })); + }, + async read(key) { + const bytes = bucket.get(key); + if (bytes === undefined) throw new Error(`no object ${key}`); + return bytes; + } + }; +} + +const fromBucket = await SkillsExtension.load( + bucketSkillFiles(new Map([['release-notes/SKILL.md', new TextEncoder().encode(RELEASE_NOTES_MD)]])) +); +``` + +The catalog logic (discovery, digests, rule checks, paging, verified reads) is the same for every adapter. Report a symlink or other special file as `kind: 'unsupported'`; the catalog skips the skill that contains one. + +## Generate skills with a `SkillSource` + +Build entries with `skillEntry` and serve them from a `SkillSource` when the catalog depends on the request. + +```ts source="../../examples/guides/servers/skills.examples.ts#skillSource" +const ON_CALL_MD = '---\nname: on-call\ndescription: Handle a page while on call.\n---\n# On call\n'; +const onCall = await skillEntry('on-call', { 'SKILL.md': ON_CALL_MD }); + +const source: SkillSource = { + list: async () => ({ skills: [onCall] }), + get: async uri => (uri === onCall.uri ? onCall : undefined), + read: async uri => (uri === onCall.uri ? { text: ON_CALL_MD, mimeType: 'text/markdown' } : undefined) +}; +const generated = new SkillsExtension({ source }); +``` + +Every source method receives the request context (`ctx`), so you can list different skills per caller. Add `readDirectory` to serve folder listings, and `listResources` to list skills in `resources/list`. + +## Handle invalid skills + +Route warnings where you want them; by default they go to stderr. + +```ts source="../../examples/guides/servers/skills.examples.ts#onWarning" +const checked = await loadSkills('./skills', { + onWarning: ({ path, message }) => console.error(`skills: ${path}: ${message}`) +}); +``` + +`loadSkills` skips, with a warning, any skill that breaks the rules, such as a `name` that differs from its folder or a symlinked file; the other skills still serve. Skills over 512 files or 16 MiB still publish, with a warning; `limits` changes those thresholds. + +Set `strict: true` to make `loadSkills` throw a `SkillCatalogError` listing every problem instead. Under `strict: true`, any symlink or other special file anywhere in the folder fails the load, even one outside every skill. + +::: warning +Do not register your own resources under `skill://`. A resource you register at a skill file's URI takes precedence over that file, and clients then fail to verify it. +::: + +::: info +Skills work on both protocol versions; the `cacheHint` option applies on 2026-07-28 only — see [Protocol versions](../protocol-versions.md). +::: + +## Recap + +- `loadSkills('./skills')` builds a catalog once; `attach(server)` serves it on any number of servers. +- Each folder with a `SKILL.md` is a skill; other folders group skills in the URI. +- `reload()` swaps in a rescanned catalog all at once and notifies connected clients. +- `memorySkillFiles`, a `SkillFiles` adapter or a `SkillSource` serve skills without a folder. +- `loadSkills` skips invalid skills with a warning; `strict: true` makes it throw instead. diff --git a/examples/guides/clients/skills.examples.ts b/examples/guides/clients/skills.examples.ts new file mode 100644 index 0000000000..fe081c6162 --- /dev/null +++ b/examples/guides/clients/skills.examples.ts @@ -0,0 +1,78 @@ +/** + * Companion example for `docs/clients/skills.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs (`pnpm docs:examples`) + * against an in-memory server and prints the output the page quotes. + * + * npx tsx guides/clients/skills.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; +import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; + +const catalog = await SkillsExtension.load( + memorySkillFiles({ + 'git-workflow/SKILL.md': + '---\nname: git-workflow\ndescription: Branching and review conventions.\n---\n# Git workflow\n\nGo through references/REVIEW.md.\n', + 'git-workflow/references/REVIEW.md': '# Review checklist\n\n- Tests pass.\n', + 'acme/billing/refunds/SKILL.md': '---\nname: refunds\ndescription: Process customer refunds.\n---\n# Refunds\n', + 'acme/billing/refunds/templates/refund-email.md': 'Your refund is on its way.\n' + }) +); +const server = new McpServer({ name: 'team-skills', version: '1.0.0' }); +catalog.attach(server); +const client = new Client({ name: 'skills-docs-host', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +//#region SkillsClient_list +import { SkillsClient, SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; + +const skills = new SkillsClient(client); +const { skills: entries } = await skills.list(); +console.log(entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter.description}`).join('\n')); +//#endregion SkillsClient_list + +//#region read_manifest +const git = entries.find(entry => entry.frontmatter.name === 'git-workflow')!; +const manifest = await skills.read(git); +console.log(manifest.verified, manifest.frontmatter); +//#endregion read_manifest + +//#region read_reference +const review = await skills.read(git, 'references/REVIEW.md'); +console.log(review.text); +//#endregion read_reference + +//#region SkillVerificationError +try { + await skills.read(git, 'references/MISSING.md'); +} catch (error) { + if (!(error instanceof SkillVerificationError)) throw error; + console.log(error.reason, error.uri); +} +//#endregion SkillVerificationError + +//#region readDirectory +if (skills.directoryRead) { + const children = await skills.readDirectory('skill://acme/billing/refunds'); + console.log(children.map(child => `${child.name} ${child.mimeType}`).join('\n')); +} +//#endregion readDirectory + +//#region get +const refunds = await skills.get('skill://acme/billing/refunds/SKILL.md'); +console.log(refunds.frontmatter.name); +//#endregion get + +// Claims the page makes, checked on every run. +if (!manifest.verified || manifest.body === undefined || review.text !== '# Review checklist\n\n- Tests pass.\n') { + throw new Error('clients/skills.md: read claims failed'); +} +await client.close(); +await server.close(); diff --git a/examples/guides/servers/skills.examples.ts b/examples/guides/servers/skills.examples.ts new file mode 100644 index 0000000000..efdcb38288 --- /dev/null +++ b/examples/guides/servers/skills.examples.ts @@ -0,0 +1,108 @@ +/** + * Companion example for `docs/servers/skills.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs (`pnpm docs:examples`) + * and prints the output the page quotes. + * + * npx tsx guides/servers/skills.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The page loads `./skills`; run from this file's folder so that path resolves. +process.chdir(path.dirname(fileURLToPath(import.meta.url))); + +//#region loadSkills_basic +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; + +const skills = await loadSkills('./skills'); + +const server = new McpServer({ name: 'team-skills', version: '1.0.0' }); +skills.attach(server); +//#endregion loadSkills_basic + +//#region entries +console.log(skills.entries.map(skill => skill.uri).join('\n')); +//#endregion entries + +//#region attach_perRequest +const handler = createMcpHandler(() => { + const mcp = new McpServer({ name: 'team-skills', version: '1.0.0' }); + skills.attach(mcp); + return mcp; +}); +//#endregion attach_perRequest + +//#region reload +const changes = await skills.reload(); +console.log(changes); +//#endregion reload + +//#region memorySkillFiles +import type { SkillFiles, SkillSource } from '@modelcontextprotocol/server/ext/skills'; +import { memorySkillFiles, skillEntry, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; + +const RELEASE_NOTES_MD = '---\nname: release-notes\ndescription: Write release notes from merged pull requests.\n---\n# Release notes\n'; + +const inline = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': RELEASE_NOTES_MD })); +//#endregion memorySkillFiles + +//#region skillFiles_custom +// Two operations over '/'-separated paths, '' being the root: here, a key-value store of objects. +function bucketSkillFiles(bucket: ReadonlyMap): SkillFiles { + return { + async list(dir) { + const prefix = dir === '' ? '' : `${dir}/`; + const children = new Map(); + for (const key of bucket.keys()) { + if (!key.startsWith(prefix)) continue; + const [name, ...rest] = key.slice(prefix.length).split('/'); + if (name !== undefined) children.set(name, rest.length > 0 ? 'directory' : 'file'); + } + return [...children].map(([name, kind]) => ({ name, kind })); + }, + async read(key) { + const bytes = bucket.get(key); + if (bytes === undefined) throw new Error(`no object ${key}`); + return bytes; + } + }; +} + +const fromBucket = await SkillsExtension.load( + bucketSkillFiles(new Map([['release-notes/SKILL.md', new TextEncoder().encode(RELEASE_NOTES_MD)]])) +); +//#endregion skillFiles_custom + +//#region skillSource +const ON_CALL_MD = '---\nname: on-call\ndescription: Handle a page while on call.\n---\n# On call\n'; +const onCall = await skillEntry('on-call', { 'SKILL.md': ON_CALL_MD }); + +const source: SkillSource = { + list: async () => ({ skills: [onCall] }), + get: async uri => (uri === onCall.uri ? onCall : undefined), + read: async uri => (uri === onCall.uri ? { text: ON_CALL_MD, mimeType: 'text/markdown' } : undefined) +}; +const generated = new SkillsExtension({ source }); +//#endregion skillSource + +//#region onWarning +const checked = await loadSkills('./skills', { + onWarning: ({ path, message }) => console.error(`skills: ${path}: ${message}`) +}); +//#endregion onWarning + +// Claims the page makes, checked on every run. +if (skills.entries.length !== 2 || checked.entries.length !== 2) throw new Error('skills.md: expected two skills'); +if (JSON.stringify(changes) !== JSON.stringify({ added: [], removed: [], changed: [] })) throw new Error('skills.md: reload claim failed'); +if (inline.entries[0]?.frontmatter.name !== 'release-notes' || fromBucket.entries[0]?.uri !== inline.entries[0]?.uri) { + throw new Error('skills.md: in-code and adapter catalogs must match'); +} +for (const extension of [inline, fromBucket, generated]) extension.attach(new McpServer({ name: 'docs-check', version: '1.0.0' })); +void handler; +await server.close(); diff --git a/examples/guides/servers/skills/acme/billing/refunds/SKILL.md b/examples/guides/servers/skills/acme/billing/refunds/SKILL.md new file mode 100644 index 0000000000..e57debb7b0 --- /dev/null +++ b/examples/guides/servers/skills/acme/billing/refunds/SKILL.md @@ -0,0 +1,6 @@ +--- +name: refunds +description: Process customer refunds. +--- + +# Refunds diff --git a/examples/guides/servers/skills/git-workflow/SKILL.md b/examples/guides/servers/skills/git-workflow/SKILL.md new file mode 100644 index 0000000000..a6ddd6893b --- /dev/null +++ b/examples/guides/servers/skills/git-workflow/SKILL.md @@ -0,0 +1,8 @@ +--- +name: git-workflow +description: Branching and review conventions. +--- + +# Git workflow + +Before opening a pull request, go through [the review checklist](references/REVIEW.md). diff --git a/examples/guides/servers/skills/git-workflow/references/REVIEW.md b/examples/guides/servers/skills/git-workflow/references/REVIEW.md new file mode 100644 index 0000000000..7c48146e18 --- /dev/null +++ b/examples/guides/servers/skills/git-workflow/references/REVIEW.md @@ -0,0 +1,3 @@ +# Review checklist + +- Tests pass. From 7ec12ec6a7dbfe5f3cacdcd9fac9f6285aafe1ea Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 08:54:43 +0300 Subject: [PATCH 28/45] docs(skills): cover invalid entries, strict and host duties; tighten wording The client guide documents list()'s invalid entries, the strict option and explaining declines; the server guide mentions prefix and pageSize and says precisely what strict rejects. Changeset wording matches the code, each snippet imports only what it uses, and the companions assert the claims the pages quote. --- .../client-modern-404-method-not-found.md | 2 +- .changeset/skills-extension.md | 2 +- docs/clients/skills.md | 20 ++++++-- docs/servers/skills.md | 18 +++++--- examples/guides/clients/skills.examples.ts | 46 +++++++++++++++++-- examples/guides/servers/skills.examples.ts | 18 ++++++-- 6 files changed, 88 insertions(+), 18 deletions(-) diff --git a/.changeset/client-modern-404-method-not-found.md b/.changeset/client-modern-404-method-not-found.md index f8d121080c..83513f796e 100644 --- a/.changeset/client-modern-404-method-not-found.md +++ b/.changeset/client-modern-404-method-not-found.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -`StreamableHTTPClientTransport`: on a 2026-07-28 connection, an HTTP `404` whose body is a JSON-RPC `-32601` error (the server does not implement the method) now rejects the request with `ProtocolError(-32601)` instead of `SdkHttpError(404)`, as a `400` with a JSON-RPC error body already did. 2025-era connections are unchanged. +`StreamableHTTPClientTransport`: on a 2026-07-28 connection, an HTTP `404` whose body is a JSON-RPC error response for the request (such as `-32601` for a method the server does not implement) now rejects the request with that error as a `ProtocolError` instead of `SdkHttpError(404)`, as a `400` with a JSON-RPC error body already did. 2025-era connections are unchanged. diff --git a/.changeset/skills-extension.md b/.changeset/skills-extension.md index ace6ce66e5..74ec24c590 100644 --- a/.changeset/skills-extension.md +++ b/.changeset/skills-extension.md @@ -12,7 +12,7 @@ Add the Skills extension (SEP-2640), reachable through new `ext/skills` subpaths - `@modelcontextprotocol/server/ext/skills`: `SkillsExtension` (`attach(server)` serves `skills/list`, `skills/get`, `resources/directory/read` and a `skill://{+path}` resource template), `SkillsExtension.load(files)` returning a `SkillCatalog` with `entries` and `reload()`, the `SkillFiles` storage adapter with `memorySkillFiles`, `SkillSource` for generated catalogs, `skillEntry`, and `SkillCatalogError`. Invalid skills are skipped with a warning (`onWarning`), or fail under `strict`. A file whose bytes are valid UTF-8 is served as `text`, whatever its MIME type; any other file as a base64 `blob`. - `@modelcontextprotocol/server/ext/skills/node`: `loadSkills(folder)` and `nodeSkillFiles(folder)`. -- `@modelcontextprotocol/client/ext/skills`: `SkillsClient` with `list()`, `get()`, `read(skill, path?)` (size, digest and `SKILL.md` frontmatter verified; a failed check is fetched once more with `cacheMode: 'refresh'`, then `SkillVerificationError` on a mismatch) and `readDirectory()`. +- `@modelcontextprotocol/client/ext/skills`: `SkillsClient` with `list()`, `get()`, `read(skill, path?)` (size, digest and `SKILL.md` frontmatter verified; a file that fails a check is fetched once more with `cacheMode: 'refresh'`, then `SkillVerificationError` on a mismatch) and `readDirectory()`. - `@modelcontextprotocol/core/ext/skills`: schemas, types and constants, including `SKILL_LIMITS`. Works on 2025-11-25 and 2026-07-28; caching fields (`ttlMs`, `cacheScope`) are sent on 2026-07-28 only. diff --git a/docs/clients/skills.md b/docs/clients/skills.md index 639700addf..5862ce5e09 100644 --- a/docs/clients/skills.md +++ b/docs/clients/skills.md @@ -7,7 +7,7 @@ shape: how-to `SkillsClient` lists a server's skills and reads their files, checking each file against what the server published. ```ts source="../../examples/guides/clients/skills.examples.ts#SkillsClient_list" -import { SkillsClient, SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; const skills = new SkillsClient(client); const { skills: entries } = await skills.list(); @@ -23,6 +23,17 @@ git-workflow: Branching and review conventions. Listing reads no skill file: each **entry** carries the skill's frontmatter and its file list with digests, enough to decide which skill to load. +## Handle refused entries + +Log the entries `list()` refused. + +```ts source="../../examples/guides/clients/skills.examples.ts#list_invalid" +const { invalid } = await skills.list(); +for (const refused of invalid) console.error(`skills: refused entry #${refused.index} ${refused.uri ?? ''}: ${refused.reason}`); +``` + +`list()` also returns `invalid`: the entries that fail validation, and every copy of a repeated URI, each with `index`, `uri` and `reason`. Against this server it is empty, so the loop prints nothing. Pass `{ strict: true }` to `new SkillsClient` to make `list()` throw an `SdkError` instead. + ## Load a skill Read the entry's `SKILL.md`. @@ -62,13 +73,15 @@ console.log(review.text); ``` -A relative path resolves against the skill's folder, as a link in `SKILL.md` does, and `read` ignores a fragment. It matches the path to the entry's file list however either side percent-encodes the name, so `references/Q&A.md` works. `bytes` always holds the file's bytes; `text` is set when the server sent the file as text. +A relative path resolves against the skill's folder, as a link in `SKILL.md` does, and `read` ignores a fragment. It matches the path to the entry's file list however either side percent-encodes the name, so `references/Q&A.md` works. `bytes` always holds the file's bytes; `text` holds the file's text when the server sent it as text, and always for the skill's own `SKILL.md`. ## Handle verification failures Catch `SkillVerificationError`. ```ts source="../../examples/guides/clients/skills.examples.ts#SkillVerificationError" +import { SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; + try { await skills.read(git, 'references/MISSING.md'); } catch (error) { @@ -123,12 +136,13 @@ An entry whose `resources` is `"dynamic"` has no file list. `read` then skips th ::: ::: warning Host responsibilities -Accept skills up to `SKILL_LIMITS` (512 files, 16 MiB), get approval before you load a skill from a server, and record which server each skill came from. Read a skill's files when you load it, never while listing. +Accept skills up to `SKILL_LIMITS` (512 files, 16 MiB) and explain why you decline one, get approval before you load a skill from a server, and record which server each skill came from. Read a skill's files when you load it, never while listing. ::: ## Recap - `new SkillsClient(client)` wraps a connected client; `list()` returns entries without reading files. +- `list()` returns refused entries in `invalid`; `strict: true` makes it throw instead. - `read(entry)` verifies `SKILL.md` (size, digest, frontmatter) and returns its frontmatter and body. - `read(entry, path)` resolves a path against the skill's folder and verifies that file. - A failed check throws `SkillVerificationError` with a `reason`. diff --git a/docs/servers/skills.md b/docs/servers/skills.md index 7bed3fb922..0581d5b94d 100644 --- a/docs/servers/skills.md +++ b/docs/servers/skills.md @@ -7,7 +7,7 @@ shape: how-to `loadSkills` reads a folder of skills once; `attach` serves it on a server. ```ts source="../../examples/guides/servers/skills.examples.ts#loadSkills_basic" -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; const skills = await loadSkills('./skills'); @@ -49,6 +49,8 @@ skill://git-workflow/SKILL.md Attach the same catalog to each server your handler creates. ```ts source="../../examples/guides/servers/skills.examples.ts#attach_perRequest" +import { createMcpHandler } from '@modelcontextprotocol/server'; + const handler = createMcpHandler(() => { const mcp = new McpServer({ name: 'team-skills', version: '1.0.0' }); skills.attach(mcp); @@ -80,21 +82,22 @@ Until you call `reload()`, the server answers a read of a file edited on disk wi Pass the files to `memorySkillFiles` where there is no file system, such as Workers or tests. ```ts source="../../examples/guides/servers/skills.examples.ts#memorySkillFiles" -import type { SkillFiles, SkillSource } from '@modelcontextprotocol/server/ext/skills'; -import { memorySkillFiles, skillEntry, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; +import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; const RELEASE_NOTES_MD = '---\nname: release-notes\ndescription: Write release notes from merged pull requests.\n---\n# Release notes\n'; const inline = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': RELEASE_NOTES_MD })); ``` -`memorySkillFiles` re-reads the object you pass whenever a walk starts (`list('')`), so `reload()` sees files you add or change later. +`memorySkillFiles` re-reads the object you pass whenever the catalog rescans (at load and on `reload()`), so `reload()` sees files you add or change later. ## Serve skills from your own storage Implement `SkillFiles`, two functions over relative paths, to load from any store. ```ts source="../../examples/guides/servers/skills.examples.ts#skillFiles_custom" +import type { SkillFiles } from '@modelcontextprotocol/server/ext/skills'; + // Two operations over '/'-separated paths, '' being the root: here, a key-value store of objects. function bucketSkillFiles(bucket: ReadonlyMap): SkillFiles { return { @@ -128,6 +131,9 @@ The catalog logic (discovery, digests, rule checks, paging, verified reads) is t Build entries with `skillEntry` and serve them from a `SkillSource` when the catalog depends on the request. ```ts source="../../examples/guides/servers/skills.examples.ts#skillSource" +import type { SkillSource } from '@modelcontextprotocol/server/ext/skills'; +import { skillEntry } from '@modelcontextprotocol/server/ext/skills'; + const ON_CALL_MD = '---\nname: on-call\ndescription: Handle a page while on call.\n---\n# On call\n'; const onCall = await skillEntry('on-call', { 'SKILL.md': ON_CALL_MD }); @@ -151,9 +157,9 @@ const checked = await loadSkills('./skills', { }); ``` -`loadSkills` skips, with a warning, any skill that breaks the rules, such as a `name` that differs from its folder or a symlinked file; the other skills still serve. Skills over 512 files or 16 MiB still publish, with a warning; `limits` changes those thresholds. +`loadSkills` skips, with a warning, any skill that breaks the rules, such as a `name` that differs from its folder or a symlinked file; the other skills still serve. Skills over 512 files or 16 MiB still publish, with a warning; `limits` changes those thresholds. The same options take `prefix` (`'acme'` serves every skill under `skill://acme/…`) and `pageSize` (entries per `skills/list` and directory page, default 100). -Set `strict: true` to make `loadSkills` throw a `SkillCatalogError` listing every problem instead. Under `strict: true`, any symlink or other special file anywhere in the folder fails the load, even one outside every skill. +Set `strict: true` to make `loadSkills` throw a `SkillCatalogError` listing every problem instead. Under `strict: true`, a symlink or other special file anywhere in the folder fails the load, even one outside every skill; hidden entries (names starting with `.`) are the exception, since the scan skips them entirely. ::: warning Do not register your own resources under `skill://`. A resource you register at a skill file's URI takes precedence over that file, and clients then fail to verify it. diff --git a/examples/guides/clients/skills.examples.ts b/examples/guides/clients/skills.examples.ts index fe081c6162..7dbd7be942 100644 --- a/examples/guides/clients/skills.examples.ts +++ b/examples/guides/clients/skills.examples.ts @@ -31,13 +31,18 @@ await server.connect(serverTransport); await client.connect(clientTransport); //#region SkillsClient_list -import { SkillsClient, SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; const skills = new SkillsClient(client); const { skills: entries } = await skills.list(); console.log(entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter.description}`).join('\n')); //#endregion SkillsClient_list +//#region list_invalid +const { invalid } = await skills.list(); +for (const refused of invalid) console.error(`skills: refused entry #${refused.index} ${refused.uri ?? ''}: ${refused.reason}`); +//#endregion list_invalid + //#region read_manifest const git = entries.find(entry => entry.frontmatter.name === 'git-workflow')!; const manifest = await skills.read(git); @@ -50,6 +55,8 @@ console.log(review.text); //#endregion read_reference //#region SkillVerificationError +import { SkillVerificationError } from '@modelcontextprotocol/client/ext/skills'; + try { await skills.read(git, 'references/MISSING.md'); } catch (error) { @@ -71,8 +78,41 @@ console.log(refunds.frontmatter.name); //#endregion get // Claims the page makes, checked on every run. -if (!manifest.verified || manifest.body === undefined || review.text !== '# Review checklist\n\n- Tests pass.\n') { - throw new Error('clients/skills.md: read claims failed'); +const claim = (ok: boolean, what: string): void => { + if (!ok) throw new Error(`clients/skills.md: ${what}`); +}; +const listed = entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter.description}`); +claim( + JSON.stringify(listed) === JSON.stringify(['refunds: Process customer refunds.', 'git-workflow: Branching and review conventions.']), + 'list() must return the quoted entries, in order' +); +claim(invalid.length === 0, 'invalid must be empty against this server'); +claim( + manifest.verified && + manifest.body !== undefined && + JSON.stringify(manifest.frontmatter) === JSON.stringify({ name: 'git-workflow', description: 'Branching and review conventions.' }), + 'read(entry) must verify SKILL.md and return its frontmatter and body' +); +claim(review.text === '# Review checklist\n\n- Tests pass.\n', 'read(entry, path) must return the quoted file text'); +let missing: unknown; +try { + await skills.read(git, 'references/MISSING.md'); +} catch (error) { + missing = error; } +claim( + missing instanceof SkillVerificationError && + missing.reason === 'unlisted' && + missing.uri === 'skill://git-workflow/references/MISSING.md', + 'an unlisted path must throw SkillVerificationError(unlisted) with the quoted URI' +); +claim(skills.directoryRead, 'the server must declare directoryRead'); +const refundsFolder = await skills.readDirectory('skill://acme/billing/refunds'); +claim( + JSON.stringify(refundsFolder.map(child => `${child.name} ${child.mimeType}`)) === + JSON.stringify(['SKILL.md text/markdown', 'templates inode/directory']), + 'the refunds folder must list the quoted children' +); +claim(refunds.frontmatter.name === 'refunds', 'get() must return the refunds entry'); await client.close(); await server.close(); diff --git a/examples/guides/servers/skills.examples.ts b/examples/guides/servers/skills.examples.ts index efdcb38288..70824457eb 100644 --- a/examples/guides/servers/skills.examples.ts +++ b/examples/guides/servers/skills.examples.ts @@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url'; process.chdir(path.dirname(fileURLToPath(import.meta.url))); //#region loadSkills_basic -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; const skills = await loadSkills('./skills'); @@ -31,6 +31,8 @@ console.log(skills.entries.map(skill => skill.uri).join('\n')); //#endregion entries //#region attach_perRequest +import { createMcpHandler } from '@modelcontextprotocol/server'; + const handler = createMcpHandler(() => { const mcp = new McpServer({ name: 'team-skills', version: '1.0.0' }); skills.attach(mcp); @@ -44,8 +46,7 @@ console.log(changes); //#endregion reload //#region memorySkillFiles -import type { SkillFiles, SkillSource } from '@modelcontextprotocol/server/ext/skills'; -import { memorySkillFiles, skillEntry, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; +import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; const RELEASE_NOTES_MD = '---\nname: release-notes\ndescription: Write release notes from merged pull requests.\n---\n# Release notes\n'; @@ -53,6 +54,8 @@ const inline = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKIL //#endregion memorySkillFiles //#region skillFiles_custom +import type { SkillFiles } from '@modelcontextprotocol/server/ext/skills'; + // Two operations over '/'-separated paths, '' being the root: here, a key-value store of objects. function bucketSkillFiles(bucket: ReadonlyMap): SkillFiles { return { @@ -80,6 +83,9 @@ const fromBucket = await SkillsExtension.load( //#endregion skillFiles_custom //#region skillSource +import type { SkillSource } from '@modelcontextprotocol/server/ext/skills'; +import { skillEntry } from '@modelcontextprotocol/server/ext/skills'; + const ON_CALL_MD = '---\nname: on-call\ndescription: Handle a page while on call.\n---\n# On call\n'; const onCall = await skillEntry('on-call', { 'SKILL.md': ON_CALL_MD }); @@ -98,7 +104,11 @@ const checked = await loadSkills('./skills', { //#endregion onWarning // Claims the page makes, checked on every run. -if (skills.entries.length !== 2 || checked.entries.length !== 2) throw new Error('skills.md: expected two skills'); +const QUOTED_URIS = ['skill://acme/billing/refunds/SKILL.md', 'skill://git-workflow/SKILL.md']; +if (JSON.stringify(skills.entries.map(skill => skill.uri)) !== JSON.stringify(QUOTED_URIS)) { + throw new Error('skills.md: the catalog must publish the quoted URIs, sorted'); +} +if (checked.entries.length !== 2) throw new Error('skills.md: expected two skills'); if (JSON.stringify(changes) !== JSON.stringify({ added: [], removed: [], changed: [] })) throw new Error('skills.md: reload claim failed'); if (inline.entries[0]?.frontmatter.name !== 'release-notes' || fromBucket.entries[0]?.uri !== inline.entries[0]?.uri) { throw new Error('skills.md: in-code and adapter catalogs must match'); From 19c43da61a3e0ee3c288f41bedaf529c2104522b Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 09:57:38 +0300 Subject: [PATCH 29/45] fix(core-internal): keep Unicode whitespace in skill frontmatter values The frontmatter parser trimmed with String.prototype.trim, which strips characters YAML 1.2 keeps as content (U+00A0, U+3000, U+2028, U+FEFF, ...). It now trims only spaces and tabs, and fails closed on a BOM before the first key, so it never reads a value differently from a YAML 1.2 parser. --- .../src/ext/skills/frontmatter.ts | 37 ++++++++++--- .../skills/frontmatter.differential.test.ts | 52 ++++++++++++++++++- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/core-internal/src/ext/skills/frontmatter.ts b/packages/core-internal/src/ext/skills/frontmatter.ts index 7fb1238f78..d9d01663d0 100644 --- a/packages/core-internal/src/ext/skills/frontmatter.ts +++ b/packages/core-internal/src/ext/skills/frontmatter.ts @@ -92,6 +92,11 @@ class Parser { parseDocument(): Record { const first = this.peek(); if (first === undefined) return {}; + // YAML drops a byte order mark before a document's first content; reading it as part of the first key + // would differ from every YAML parser, so fail closed (a BOM before the opening `---` is fine). + if (first.text.startsWith('\uFEFF')) { + throw new FrontmatterError('a byte order mark inside the frontmatter is not supported', first.no); + } if (isSequenceItem(first.text) || matchKey(first.text, first.no) === undefined) { throw new FrontmatterError('frontmatter must be a mapping of keys to values', first.no); } @@ -128,7 +133,7 @@ class Parser { if (line.indent > indent) throw new FrontmatterError('unexpected indentation', line.no); if (!isSequenceItem(line.text)) break; const after = line.text.slice(1); - const rest = after.trimStart(); + const rest = trimWhiteStart(after); const column = line.indent + 1 + (after.length - rest.length); if (rest !== '' && !rest.startsWith('#') && (isSequenceItem(rest) || matchKey(rest, line.no) !== undefined)) { // `- key: value` or `- - item`: the item is a block node that starts at `column`. @@ -254,7 +259,7 @@ function matchKey(text: string, no: number): KeyMatch | undefined { if (first === '"' || first === "'") { const { value, end } = readQuoted(text, 0, no); const after = text.slice(end); - if (after === ':' || /^:[\t ]/.test(after)) return { key: checkKey(value, no), rest: after.slice(1).trimStart() }; + if (after === ':' || /^:[\t ]/.test(after)) return { key: checkKey(value, no), rest: trimWhiteStart(after.slice(1)) }; return undefined; } if (first === '?') throw new FrontmatterError('complex keys ("?") are not supported', no); @@ -266,9 +271,9 @@ function matchKey(text: string, no: number): KeyMatch | undefined { if (char === ':') { const next = text[index + 1]; if (next === undefined || next === ' ' || next === '\t') { - const key = text.slice(0, index).trimEnd(); + const key = trimWhiteEnd(text.slice(0, index)); if (key === '') return undefined; - return { key: resolveKey(key, no), rest: text.slice(index + 1).trimStart() }; + return { key: resolveKey(key, no), rest: trimWhiteStart(text.slice(index + 1)) }; } } } @@ -293,14 +298,30 @@ const COMMENT = /[\t ]#/; function stripComment(text: string): string { const match = COMMENT.exec(text); - return (match === null ? text : text.slice(0, match.index)).trimEnd(); + return trimWhiteEnd(match === null ? text : text.slice(0, match.index)); } function expectOnlyComment(after: string, no: number): void { - if (after.trim() === '' || /^[\t ]+#/.test(after)) return; + if (trimWhiteStart(after) === '' || /^[\t ]+#/.test(after)) return; throw new FrontmatterError('unexpected text after the value', no); } +// YAML white space is only space and tab (YAML 1.2 `s-white`). `String.prototype.trim` and friends also strip +// U+00A0, U+3000, U+2028, U+FEFF and other Unicode spaces, which YAML keeps as content: using them here would read +// `description: a` as "a" where every YAML 1.2 parser (and so every other SDK) reads "a". + +/** `text` without leading spaces and tabs. */ +function trimWhiteStart(text: string): string { + return text.slice(skipSpaces(text, 0)); +} + +/** `text` without trailing spaces and tabs. */ +function trimWhiteEnd(text: string): string { + let end = text.length; + while (end > 0 && (text[end - 1] === ' ' || text[end - 1] === '\t')) end--; + return text.slice(0, end); +} + const NULL = /^(?:~|null|Null|NULL)$/; const TRUE = /^(?:true|True|TRUE)$/; const FALSE = /^(?:false|False|FALSE)$/; @@ -448,7 +469,7 @@ function readFlowKey(text: string, start: number, no: number): { key: string; en if (char === '[' || char === '{') throw new FrontmatterError('complex keys (flow collections as keys) are not supported', no); assertFlowPlainStart(text, index, no); const end = scanFlowPlain(text, index); - const raw = text.slice(index, end).trim(); + const raw = trimWhiteEnd(text.slice(index, end)); const after = text[end + 1]; if (raw === '' || text[end] !== ':' || (after !== ' ' && after !== '\t')) { throw new FrontmatterError('expected "key: value" in a flow mapping', no); @@ -490,7 +511,7 @@ function readFlowValue(text: string, start: number, no: number, depth: number): assertFlowPlainStart(text, index, no); const end = scanFlowPlain(text, index); if (text[end] === ':') throw new FrontmatterError('mappings inside flow collections are not supported; quote the value', no); - const raw = text.slice(index, end).trim(); + const raw = trimWhiteEnd(text.slice(index, end)); if (raw === '') throw new FrontmatterError('empty value in a flow collection', no); return { value: resolvePlain(raw, no), end }; } diff --git a/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts index 33870f8d19..1b913e852e 100644 --- a/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts +++ b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts @@ -45,7 +45,10 @@ const REJECTED: readonly string[] = [ 'map: {k: [x:]}\n', 'map: {[x]: y}\n', 'map: {k: v, [x]: y}\n', - 'map: {{x: 1}: y}\n' + 'map: {{x: 1}: y}\n', + // yaml drops a byte order mark before the first content; the parser does not guess. + '\uFEFFname: x\n', + '# comment\n\uFEFFname: x\n' ]; /** Invalid YAML: the yaml library rejects it, so the parser must too. */ @@ -77,6 +80,38 @@ const INVALID: readonly string[] = [ 'a: {x[: y}\n' ]; +/** + * Characters that JavaScript's `trim` strips (NEL aside, which it keeps) but that YAML 1.2 reads as content: its + * only white space is space and tab (`s-white`). + */ +const NON_YAML_SPACES: Readonly> = { + 'U+00A0': '\u00A0', + 'U+2003': '\u2003', + 'U+3000': '\u3000', + 'U+2028': '\u2028', + 'U+2029': '\u2029', + 'U+FEFF': '\uFEFF', + 'U+000B': '\u000B', + 'U+000C': '\u000C', + 'U+0085': '\u0085' +}; + +/** Every place the parser skips white space, with `c` where a trim would wrongly drop it. */ +const SPACE_POSITIONS: ReadonlyArray<(c: string) => string> = [ + c => `name: x\ndescription: a${c}\n`, + c => `name: x\ndescription: ${c}a\n`, + c => `name: x\ndescription: ${c}\n`, + c => `name${c}: x\n`, + c => `description: a${c}# not a comment\n`, + c => `description: a${c}b\n`, + c => `description: a${c}\n continued\n`, + c => `description: a\n ${c}\n`, + c => `description: |\n a${c}\n`, + c => `tags: [a${c}, ${c}b]\n`, + c => `map: {k${c}: v${c}}\n`, + c => `tags:\n- ${c}a\nitems:\n- ${c}k: v\n` +]; + const OPTIONS = { version: '1.2', schema: 'core', uniqueKeys: true, merge: false } as const; describe('frontmatter parser agrees with the yaml library (YAML 1.2 core schema)', () => { @@ -94,3 +129,18 @@ describe('frontmatter parser agrees with the yaml library (YAML 1.2 core schema) expect(() => parseFrontmatterYaml(source)).toThrow(FrontmatterError); }); }); + +describe('Unicode white space other than space and tab is content, as in yaml', () => { + const cases = Object.entries(NON_YAML_SPACES).flatMap(([name, c]) => SPACE_POSITIONS.map(position => [name, position(c)] as const)); + + it.each(cases)('%s: reads %j exactly like yaml', (_name, source) => { + const expected = YAML.parse(source, OPTIONS) as unknown; + expect(parseFrontmatterYaml(source)).toEqual(expected); + }); + + it.each(Object.entries(NON_YAML_SPACES))('%s after a quoted value is rejected, like yaml', (_name, c) => { + const source = `description: "a"${c}\n`; + expect(() => YAML.parse(source, OPTIONS)).toThrow(); + expect(() => parseFrontmatterYaml(source)).toThrow(FrontmatterError); + }); +}); From 0ddb7b43828fea6ec3def7b40bb19f1810555b2a Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 09:57:38 +0300 Subject: [PATCH 30/45] fix(server): brand SkillCatalogError across bundle copies SkillCatalogError carries the mcpBrand like the SDK's other errors, so instanceof works across bundle copies, and both packages' brand-conformance tests now walk the ext/skills entries. --- .../test/client/errorBrandConformance.test.ts | 25 +++++++---- packages/server/src/ext/skills/errors.ts | 31 +++++++++++++- .../test/server/errorBrandConformance.test.ts | 41 ++++++++++++++----- 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/packages/client/test/client/errorBrandConformance.test.ts b/packages/client/test/client/errorBrandConformance.test.ts index 7265b8c29b..2e709a749a 100644 --- a/packages/client/test/client/errorBrandConformance.test.ts +++ b/packages/client/test/client/errorBrandConformance.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; +import * as skills from '../../src/ext/skills/index'; import * as barrel from '../../src/index'; +/** Every public entry that can export an error class: the root and the skills extension's subpath. */ +const SURFACES: ReadonlyArray> = [barrel, skills]; + /** * Enforces the cross-bundle branding participation criterion from * `core-internal/src/errors/crossBundleBrand.ts`: every error class exported @@ -17,22 +21,26 @@ import * as barrel from '../../src/index'; /** Error classes exported on purpose without a brand. Justify every entry. */ const UNBRANDED_ALLOWLIST: ReadonlySet = new Set(); -function exportedErrorClasses(mod: Record): Array<[string, Function]> { - return Object.entries(mod) - .filter((entry): entry is [string, Function] => { - const v = entry[1]; - return typeof v === 'function' && v !== Error && v.prototype instanceof Error; - }) - .sort(([a], [b]) => a.localeCompare(b)); +function exportedErrorClasses(surfaces: ReadonlyArray>): Array<[string, Function]> { + const found = new Map(); + for (const surface of surfaces) { + for (const [name, v] of Object.entries(surface)) { + if (typeof v !== 'function' || v === Error || !(v.prototype instanceof Error)) continue; + if (found.has(name) && found.get(name) !== v) throw new Error(`two entries export different error classes named ${name}`); + found.set(name, v); + } + } + return [...found].sort(([a], [b]) => a.localeCompare(b)); } describe('error brand conformance (client export surface)', () => { - const classes = exportedErrorClasses(barrel as Record); + const classes = exportedErrorClasses(SURFACES); it('finds the export surface (guards against a broken walker)', () => { expect(classes.length).toBeGreaterThanOrEqual(15); expect(classes.map(([n]) => n)).toContain('ProtocolError'); expect(classes.map(([n]) => n)).toContain('OAuthClientFlowError'); + expect(classes.map(([n]) => n)).toContain('SkillVerificationError'); }); it('every exported error class is branded (or explicitly allowlisted)', () => { @@ -81,6 +89,7 @@ describe('error brand conformance (client export surface)', () => { IssuerMismatchError: 'mcp.IssuerMismatchError', OAuthClientFlowError: 'mcp.OAuthClientFlowError', RegistrationRejectedError: 'mcp.RegistrationRejectedError', + SkillVerificationError: 'mcp.SkillVerificationError', SseError: 'mcp.SseError', UnauthorizedError: 'mcp.UnauthorizedError' }); diff --git a/packages/server/src/ext/skills/errors.ts b/packages/server/src/ext/skills/errors.ts index f71f868dda..e25c41462e 100644 --- a/packages/server/src/ext/skills/errors.ts +++ b/packages/server/src/ext/skills/errors.ts @@ -1,7 +1,35 @@ +import { brandedHasInstance, stampErrorBrands } from '@modelcontextprotocol/core-internal'; + import type { SkillWarning } from './types'; -/** Thrown when a skill catalog cannot be built: storage failures always, skill problems under `strict`. */ +/** + * Thrown when a skill catalog cannot be built: storage failures always, skill problems under `strict`. + * Branded, so `instanceof` also matches an instance from another bundled copy of the SDK. + */ export class SkillCatalogError extends Error { + static { + Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SkillCatalogError' }); + } + + static override [Symbol.hasInstance](value: unknown): boolean { + return brandedHasInstance(this, value); + } + + /** + * Brand-based type guard: equivalent to `value instanceof this`, as an + * explicit static predicate (the axios/AWS-SDK `isInstance` style). Must be + * invoked on the class — in callback position write + * `v => SkillCatalogError.isInstance(v)`, not `.filter(SkillCatalogError.isInstance)`. + */ + static isInstance unknown>(this: T, value: unknown): value is InstanceType { + if (typeof this !== 'function') { + throw new TypeError( + 'isInstance must be called on the class (e.g. `SkillCatalogError.isInstance(value)`); for callbacks use `v => SkillCatalogError.isInstance(v)`' + ); + } + return brandedHasInstance(this, value); + } + readonly problems: readonly SkillWarning[]; constructor(problems: readonly SkillWarning[]) { @@ -9,6 +37,7 @@ export class SkillCatalogError extends Error { super(`${problems.length} skill problem${problems.length === 1 ? '' : 's'}:\n${lines}`); this.name = 'SkillCatalogError'; this.problems = problems; + stampErrorBrands(this, new.target); } } diff --git a/packages/server/test/server/errorBrandConformance.test.ts b/packages/server/test/server/errorBrandConformance.test.ts index 8f8445d437..37800695e0 100644 --- a/packages/server/test/server/errorBrandConformance.test.ts +++ b/packages/server/test/server/errorBrandConformance.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import * as skills from '../../src/ext/skills/index'; +import * as skillsNode from '../../src/ext/skills/node'; import * as barrel from '../../src/index'; +/** Every public entry that can export an error class: the root and the skills extension's subpaths. */ +const SURFACES: ReadonlyArray> = [barrel, skills, skillsNode]; + /** * Server-side twin of the client's errorBrandConformance test: every error * class exported from this package's public surface must carry an own @@ -15,21 +20,25 @@ import * as barrel from '../../src/index'; /** Error classes exported on purpose without a brand. Justify every entry. */ const UNBRANDED_ALLOWLIST: ReadonlySet = new Set([]); -function exportedErrorClasses(mod: Record): Array<[string, Function]> { - return Object.entries(mod) - .filter((entry): entry is [string, Function] => { - const v = entry[1]; - return typeof v === 'function' && v !== Error && v.prototype instanceof Error; - }) - .sort(([a], [b]) => a.localeCompare(b)); +function exportedErrorClasses(surfaces: ReadonlyArray>): Array<[string, Function]> { + const found = new Map(); + for (const surface of surfaces) { + for (const [name, v] of Object.entries(surface)) { + if (typeof v !== 'function' || v === Error || !(v.prototype instanceof Error)) continue; + if (found.has(name) && found.get(name) !== v) throw new Error(`two entries export different error classes named ${name}`); + found.set(name, v); + } + } + return [...found].sort(([a], [b]) => a.localeCompare(b)); } describe('error brand conformance (server export surface)', () => { - const classes = exportedErrorClasses(barrel as Record); + const classes = exportedErrorClasses(SURFACES); it('finds the export surface (guards against a broken walker)', () => { expect(classes.length).toBeGreaterThanOrEqual(5); expect(classes.map(([n]) => n)).toContain('ProtocolError'); + expect(classes.map(([n]) => n)).toContain('SkillCatalogError'); }); it('every exported error class is branded (or explicitly allowlisted)', () => { @@ -76,7 +85,7 @@ describe('error brand conformance (server export surface)', () => { 'UnsupportedProtocolVersionError', 'UrlElicitationRequiredError' ]); - const SERVER_LOCAL_BRANDS: Record = {}; + const SERVER_LOCAL_BRANDS: Record = { SkillCatalogError: 'mcp.SkillCatalogError' }; it('pins every server-local brand string (add new entries here — renames must be deliberate)', () => { const brands = Object.fromEntries( @@ -86,4 +95,16 @@ describe('error brand conformance (server export surface)', () => { ); expect(brands).toEqual(SERVER_LOCAL_BRANDS); }); + + it('SkillCatalogError matches across module copies', async () => { + vi.resetModules(); + const foreign = await import('../../src/ext/skills/errors'); + // Guard the premise: a cached module would make the checks pass through prototype identity. + expect(foreign.SkillCatalogError).not.toBe(skills.SkillCatalogError); + const error = new foreign.SkillCatalogError([{ path: 'git-workflow/SKILL.md', message: 'invalid' }]); + expect(error instanceof skills.SkillCatalogError).toBe(true); + expect(skills.SkillCatalogError.isInstance(error)).toBe(true); + expect(error.name).toBe('SkillCatalogError'); + expect(new Error('plain') instanceof skills.SkillCatalogError).toBe(false); + }); }); From 7ee8d919f3402746c8f7da39ce177b8fe672864d Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 17 Oct 2026 09:57:38 +0300 Subject: [PATCH 31/45] fix(skills): keep skills out of the root bundles and harden the server extension - core-internal exposes its skills internals at ./ext/skills instead of its root barrel, so neither package's root entry loads the parser or imports @modelcontextprotocol/core/ext/skills (barrelClean pins both). - SkillCatalog works where WeakRef is missing (Workers before 2025-05-05). - reload() reports listChanged, and the docs say to call the handler's notify when serving through createMcpHandler. - attach() rejects a second skills extension on the same server before changing anything, and registers the capability last. - The attach hook is package-internal, out of the public declarations. - Catalog entries are frozen, so editing them cannot change what is served. - nodeSkillFiles refuses paths that resolve outside its root, on Windows too. --- docs/servers/skills.md | 14 +++- examples/client-quickstart/tsconfig.json | 3 + examples/guides/servers/skills.examples.ts | 24 ++++++- examples/server-quickstart/tsconfig.json | 3 + examples/shared/tsconfig.json | 3 + examples/tsconfig.json | 3 + packages/client/src/ext/skills/entries.ts | 2 +- .../client/src/ext/skills/skillsClient.ts | 2 +- .../client/test/client/barrelClean.test.ts | 46 +++++++++++-- packages/client/test/ext/skills/fixtures.ts | 2 +- packages/client/test/ext/skills/read.test.ts | 3 +- packages/client/tsconfig.json | 3 + packages/client/tsdown.config.ts | 1 + packages/core-internal/package.json | 4 ++ .../core-internal/src/ext/skills/index.ts | 9 ++- packages/core-internal/src/ext/skills/mime.ts | 7 -- packages/core-internal/src/index.ts | 6 +- packages/middleware/express/tsconfig.json | 3 + packages/middleware/hono/tsconfig.json | 3 + packages/middleware/node/tsconfig.json | 3 + .../server/src/ext/skills/catalogBuild.ts | 15 +++- .../server/src/ext/skills/catalogSource.ts | 10 +-- packages/server/src/ext/skills/extension.ts | 68 +++++++++++++------ packages/server/src/ext/skills/files.ts | 2 +- packages/server/src/ext/skills/fsPath.ts | 27 ++++++++ packages/server/src/ext/skills/node.ts | 6 +- packages/server/src/ext/skills/paging.ts | 11 +-- packages/server/src/ext/skills/skillEntry.ts | 2 +- packages/server/src/ext/skills/types.ts | 5 ++ .../server/test/ext/skills/catalog.test.ts | 65 +++++++++++++++++- .../server/test/ext/skills/extension.test.ts | 52 +++++++++++++- packages/server/test/ext/skills/node.test.ts | 33 ++++++++- .../server/test/server/barrelClean.test.ts | 45 ++++++++++++ packages/server/tsconfig.json | 3 + packages/server/tsdown.config.ts | 1 + test/conformance/tsconfig.json | 3 + test/e2e/tsconfig.json | 3 + test/helpers/tsconfig.json | 3 + .../test/server/cloudflareWorkers.test.ts | 40 ++++++++++- .../test/skills/skillsEndToEnd.test.ts | 46 ++++++++++++- test/integration/tsconfig.json | 3 + 41 files changed, 507 insertions(+), 80 deletions(-) create mode 100644 packages/server/src/ext/skills/fsPath.ts diff --git a/docs/servers/skills.md b/docs/servers/skills.md index 0581d5b94d..b6193b0530 100644 --- a/docs/servers/skills.md +++ b/docs/servers/skills.md @@ -72,10 +72,18 @@ console.log(changes); With nothing changed on disk, `reload()` reports no changes: ```text -{ added: [], removed: [], changed: [] } +{ added: [], removed: [], changed: [], listChanged: false } ``` -Until you call `reload()`, the server answers a read of a file edited on disk with an error instead of unverified content. `reload()` sends connected clients `notifications/resources/list_changed` when a skill appears, disappears, or changes its name or description. If the rescan fails, `reload()` rejects and the old catalog keeps serving. +Until you call `reload()`, the server answers a read of a file edited on disk with an error instead of unverified content. If the rescan fails, `reload()` rejects and the old catalog keeps serving. + +`listChanged` is `true` when a skill appears, disappears, or changes its name or description. A server that stays connected (on stdio, or in a stateful 2025-11-25 HTTP session) then sends its clients `notifications/resources/list_changed` itself. A server that `createMcpHandler` builds lives for one request, so publish the change on the handler instead: + +```ts source="../../examples/guides/servers/skills.examples.ts#reload_notify" +if (changes.listChanged) handler.notify.resourcesChanged(); +``` + +Every open `subscriptions/listen` stream that opted in to resource list changes receives the notification. ## Serve skills defined in code @@ -173,6 +181,6 @@ Skills work on both protocol versions; the `cacheHint` option applies on 2026-07 - `loadSkills('./skills')` builds a catalog once; `attach(server)` serves it on any number of servers. - Each folder with a `SKILL.md` is a skill; other folders group skills in the URI. -- `reload()` swaps in a rescanned catalog all at once and notifies connected clients. +- `reload()` swaps in a rescanned catalog all at once; behind `createMcpHandler`, call `handler.notify.resourcesChanged()` when it reports `listChanged`. - `memorySkillFiles`, a `SkillFiles` adapter or a `SkillSource` serve skills without a folder. - `loadSkills` skips invalid skills with a warning; `strict: true` makes it throw instead. diff --git a/examples/client-quickstart/tsconfig.json b/examples/client-quickstart/tsconfig.json index d55d7f0b36..e227905f23 100644 --- a/examples/client-quickstart/tsconfig.json +++ b/examples/client-quickstart/tsconfig.json @@ -21,6 +21,9 @@ ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ] } }, diff --git a/examples/guides/servers/skills.examples.ts b/examples/guides/servers/skills.examples.ts index 70824457eb..a627cf62e7 100644 --- a/examples/guides/servers/skills.examples.ts +++ b/examples/guides/servers/skills.examples.ts @@ -40,11 +40,19 @@ const handler = createMcpHandler(() => { }); //#endregion attach_perRequest +// What the handler publishes to `subscriptions/listen` streams, recorded for the checks at the end. +const published: string[] = []; +handler.bus.subscribe(event => published.push(event.kind)); + //#region reload const changes = await skills.reload(); console.log(changes); //#endregion reload +//#region reload_notify +if (changes.listChanged) handler.notify.resourcesChanged(); +//#endregion reload_notify + //#region memorySkillFiles import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; @@ -109,10 +117,22 @@ if (JSON.stringify(skills.entries.map(skill => skill.uri)) !== JSON.stringify(QU throw new Error('skills.md: the catalog must publish the quoted URIs, sorted'); } if (checked.entries.length !== 2) throw new Error('skills.md: expected two skills'); -if (JSON.stringify(changes) !== JSON.stringify({ added: [], removed: [], changed: [] })) throw new Error('skills.md: reload claim failed'); +if (JSON.stringify(changes) !== JSON.stringify({ added: [], removed: [], changed: [], listChanged: false })) { + throw new Error('skills.md: reload claim failed'); +} +// reload_notify: nothing changed, so nothing was published; a skill appearing is a listing change the handler publishes. +if (published.length > 0) throw new Error('skills.md: an unchanged reload must not publish'); +const record: Record = { 'release-notes/SKILL.md': RELEASE_NOTES_MD }; +const live = await SkillsExtension.load(memorySkillFiles(record)); +record['on-call/SKILL.md'] = ON_CALL_MD; +const liveChanges = await live.reload(); +if (liveChanges.listChanged) handler.notify.resourcesChanged(); +if (!liveChanges.listChanged || JSON.stringify(published) !== JSON.stringify(['resources_list_changed'])) { + throw new Error('skills.md: a skill added must set listChanged, and handler.notify must publish resources/list_changed'); +} if (inline.entries[0]?.frontmatter.name !== 'release-notes' || fromBucket.entries[0]?.uri !== inline.entries[0]?.uri) { throw new Error('skills.md: in-code and adapter catalogs must match'); } for (const extension of [inline, fromBucket, generated]) extension.attach(new McpServer({ name: 'docs-check', version: '1.0.0' })); -void handler; +await handler.close(); await server.close(); diff --git a/examples/server-quickstart/tsconfig.json b/examples/server-quickstart/tsconfig.json index f3e03d7d47..ad7cceae55 100644 --- a/examples/server-quickstart/tsconfig.json +++ b/examples/server-quickstart/tsconfig.json @@ -21,6 +21,9 @@ ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ] } }, diff --git a/examples/shared/tsconfig.json b/examples/shared/tsconfig.json index 4ad8069783..75cedeee6c 100644 --- a/examples/shared/tsconfig.json +++ b/examples/shared/tsconfig.json @@ -22,6 +22,9 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 8cdaf6e048..28288e5c6d 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -31,6 +31,9 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"] } } diff --git a/packages/client/src/ext/skills/entries.ts b/packages/client/src/ext/skills/entries.ts index 59281d57d6..e8cd9fbf2b 100644 --- a/packages/client/src/ext/skills/entries.ts +++ b/packages/client/src/ext/skills/entries.ts @@ -1,6 +1,6 @@ import type { Skill } from '@modelcontextprotocol/core/ext/skills'; import { SkillSchema } from '@modelcontextprotocol/core/ext/skills'; -import { canonicalUri, validateSkillEntry } from '@modelcontextprotocol/core-internal'; +import { canonicalUri, validateSkillEntry } from '@modelcontextprotocol/core-internal/ext/skills'; /** A `skills/list` entry the client refused, and why. `index` counts across every page walked. */ export interface InvalidSkillEntry { diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index daa15c562d..d10b742831 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -19,7 +19,7 @@ import { skillFileKey, skillRootUri, splitFrontmatter -} from '@modelcontextprotocol/core-internal'; +} from '@modelcontextprotocol/core-internal/ext/skills'; import type { RequestOptions } from '@modelcontextprotocol/core-internal/public'; import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal/public'; import type * as z from 'zod/v4'; diff --git a/packages/client/test/client/barrelClean.test.ts b/packages/client/test/client/barrelClean.test.ts index 567543deed..94412bba87 100644 --- a/packages/client/test/client/barrelClean.test.ts +++ b/packages/client/test/client/barrelClean.test.ts @@ -14,20 +14,27 @@ const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\ // Anchored at start-of-line so JSDoc-example `from 'ajv'` strings in vendored chunks don't match. const VALIDATOR_BACKEND_IMPORT = /^import[^\n]*?from\s+["'](?:ajv|ajv-formats|@cfworker\/json-schema)["']/m; const ROOT_VALIDATOR_EXPORTS = ['AjvJsonSchemaValidator', 'CfWorkerJsonSchemaValidator', 'CfWorkerSchemaDraft']; +// The skills extension's shared schemas: a static, side-effect or dynamic import of the external subpath. +const CORE_SKILLS_IMPORT = /\b(?:from|import)\s*\(?\s*["']@modelcontextprotocol\/core\/ext\/skills["']/; +// The skills extension's own code (core-internal's `ext/skills`): the frontmatter parser and the entry builder. +const SKILLS_CODE = /\b(?:FrontmatterError|parseFrontmatterYaml|buildSkillEntry|skillEntryProblems)\b/; -function chunkImportsOf(entryPath: string): string[] { +/** + * An ESM entry and every chunk it imports, following relative `from` and side-effect `import` specifiers, both + * `./` and `../` (nested entries import `../../chunk.mjs`, and side-effect-only `import "../../chunk.mjs"`), with or + * without a space before the quote (`from"./x.mjs"`). Same walker as the server package's barrelClean test. + */ +function moduleGraphOf(entryPath: string): string[] { const visited = new Set(); const queue = [entryPath]; while (queue.length > 0) { const file = queue.shift()!; if (visited.has(file)) continue; visited.add(file); - const src = readFileSync(file, 'utf8'); - for (const m of src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)) { + for (const m of readFileSync(file, 'utf8').matchAll(/\b(?:from|import)\s*["'](\.{1,2}\/[^"']+?\.mjs)["']/g)) { queue.push(join(dirname(file), m[1]!)); } } - visited.delete(entryPath); return [...visited]; } @@ -49,7 +56,7 @@ describe('@modelcontextprotocol/client root entry is browser-safe', () => { test('chunks transitively imported by dist/index.mjs contain no process-spawning runtime imports', () => { const entry = join(distDir, 'index.mjs'); - for (const chunk of chunkImportsOf(entry)) { + for (const chunk of moduleGraphOf(entry)) { expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( expect.objectContaining({ content: expect.stringMatching(NODE_ONLY) }) ); @@ -68,7 +75,7 @@ describe('@modelcontextprotocol/client root entry is browser-safe', () => { const entry = join(distDir, shim); expect(readFileSync(entry, 'utf8')).not.toMatch(VALIDATOR_BACKEND_IMPORT); - for (const chunk of chunkImportsOf(entry)) { + for (const chunk of moduleGraphOf(entry)) { expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( expect.objectContaining({ content: expect.stringMatching(VALIDATOR_BACKEND_IMPORT) }) ); @@ -96,3 +103,30 @@ describe('@modelcontextprotocol/client root entry is browser-safe', () => { } }); }); + +describe('@modelcontextprotocol/client root leaves the skills extension out', () => { + beforeAll(async () => { + await ensureBuilt(pkgDir); + }, 180_000); + + test('the root entry does not export the skills extension', () => { + expect(rootExportBlockOf(readFileSync(join(distDir, 'index.mjs'), 'utf8'))).not.toMatch( + /\b(?:SkillsClient|SkillVerificationError)\b/ + ); + }); + + test('dist/index.mjs and every chunk it imports carry no skills code and no @modelcontextprotocol/core/ext/skills import', () => { + // Users who never import ext/skills must not load (or bundle) the frontmatter parser or the skills schemas. + for (const file of moduleGraphOf(join(distDir, 'index.mjs'))) { + const content = readFileSync(file, 'utf8'); + expect({ file, content }).not.toEqual(expect.objectContaining({ content: expect.stringMatching(CORE_SKILLS_IMPORT) })); + expect({ file, content }).not.toEqual(expect.objectContaining({ content: expect.stringMatching(SKILLS_CODE) })); + } + }); + + test('the ext/skills entry graph is where the skills code and the core/ext/skills import live (guards the patterns)', () => { + const graph = moduleGraphOf(join(distDir, 'ext/skills/index.mjs')).map(file => readFileSync(file, 'utf8')); + expect(graph.some(content => CORE_SKILLS_IMPORT.test(content))).toBe(true); + expect(graph.some(content => SKILLS_CODE.test(content))).toBe(true); + }); +}); diff --git a/packages/client/test/ext/skills/fixtures.ts b/packages/client/test/ext/skills/fixtures.ts index 513c1b16af..b74cda1390 100644 --- a/packages/client/test/ext/skills/fixtures.ts +++ b/packages/client/test/ext/skills/fixtures.ts @@ -1,7 +1,7 @@ import type { Skill } from '@modelcontextprotocol/core/ext/skills'; import { SKILLS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/skills'; import type { ServerCapabilities } from '@modelcontextprotocol/core-internal'; -import { buildSkillEntry, encodeSegment, encodeUtf8 } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, encodeSegment, encodeUtf8 } from '@modelcontextprotocol/core-internal/ext/skills'; export const CAPABILITIES: ServerCapabilities = { resources: {}, extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: true } } }; diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts index 3780137c6b..f7a9c98714 100644 --- a/packages/client/test/ext/skills/read.test.ts +++ b/packages/client/test/ext/skills/read.test.ts @@ -1,5 +1,6 @@ import type { Skill } from '@modelcontextprotocol/core/ext/skills'; -import { buildSkillEntry, encodeUtf8, joinUri, SdkErrorCode, sha256Digest } from '@modelcontextprotocol/core-internal'; +import { SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, encodeUtf8, joinUri, sha256Digest } from '@modelcontextprotocol/core-internal/ext/skills'; import { describe, expect, it, vi } from 'vitest'; import type { SkillsClientOptions } from '../../../src/ext/skills/index'; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 86714b5a03..4fb474f8fb 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -11,6 +11,9 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/validators/ajv": [ "./node_modules/@modelcontextprotocol/core-internal/src/validators/ajvProvider.ts" ], diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index bc59959462..d4a8de998a 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ '@modelcontextprotocol/core/ext/skills': ['../core/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], + '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 78abd04715..23d5d1761c 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -33,6 +33,10 @@ "types": "./src/exports/public/index.ts", "import": "./src/exports/public/index.ts" }, + "./ext/skills": { + "types": "./src/ext/skills/index.ts", + "import": "./src/ext/skills/index.ts" + }, "./validators/ajv": { "types": "./src/validators/ajvProvider.ts", "import": "./src/validators/ajvProvider.ts" diff --git a/packages/core-internal/src/ext/skills/index.ts b/packages/core-internal/src/ext/skills/index.ts index af6ccef193..cd8a46f5df 100644 --- a/packages/core-internal/src/ext/skills/index.ts +++ b/packages/core-internal/src/ext/skills/index.ts @@ -1,11 +1,16 @@ +// @modelcontextprotocol/core-internal/ext/skills +// // Internal helpers for the MCP Skills extension (SEP-2640), shared by the -// client and server packages. Not part of any public entry point. +// client and server packages. Not part of any public entry point, and not on +// core-internal's root barrel: import them from this subpath, so they (and the +// `@modelcontextprotocol/core/ext/skills` import in `entry.ts`) stay out of the +// client and server root module graphs. export { decodeBase64, decodeUtf8Lossless, encodeBase64, encodeUtf8, sha256Digest } from './bytes'; export type { SkillFileInput } from './entry'; export { buildSkillEntry, SKILL_NAME_PATTERN, skillEntryProblems, validateSkillEntry } from './entry'; export type { ParsedSkillMarkdown, SplitSkillMarkdown } from './frontmatter'; export { FrontmatterError, parseFrontmatterYaml, parseSkillMarkdown, splitFrontmatter } from './frontmatter'; -export { isTextualMimeType, mimeTypeFor } from './mime'; +export { mimeTypeFor } from './mime'; export { canonicalUri, compareUris, diff --git a/packages/core-internal/src/ext/skills/mime.ts b/packages/core-internal/src/ext/skills/mime.ts index bb756dfc33..34cf076028 100644 --- a/packages/core-internal/src/ext/skills/mime.ts +++ b/packages/core-internal/src/ext/skills/mime.ts @@ -24,16 +24,9 @@ const BY_EXTENSION: Readonly> = { pdf: 'application/pdf' }; -const TEXTUAL = new Set(['application/json', 'application/yaml', 'application/x-sh', 'application/xml', 'image/svg+xml']); - /** MIME type from the file extension; `application/octet-stream` when unknown. */ export function mimeTypeFor(fileName: string): string { const dot = fileName.lastIndexOf('.'); if (dot <= 0) return 'application/octet-stream'; return BY_EXTENSION[fileName.slice(dot + 1).toLowerCase()] ?? 'application/octet-stream'; } - -/** True for types served as `text` when the bytes decode losslessly. */ -export function isTextualMimeType(mimeType: string): boolean { - return mimeType.startsWith('text/') || TEXTUAL.has(mimeType); -} diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 3c3514737f..22658d9537 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -1,7 +1,11 @@ export * from './auth/errors'; export * from './errors/crossBundleBrand'; export * from './errors/sdkErrors'; -export * from './ext/skills/index'; +// The Skills extension's internals (SEP-2640) are deliberately NOT on this barrel: +// they live on the `./ext/skills` subpath, so only the client and server +// `ext/skills` entries load the frontmatter parser and the +// `@modelcontextprotocol/core/ext/skills` import (on this barrel they landed in +// every package's root module graph). export * from './shared/auth'; export * from './shared/authUtils'; export * from './shared/clientCapabilityRequirements'; diff --git a/packages/middleware/express/tsconfig.json b/packages/middleware/express/tsconfig.json index 01f4447bdb..aa149835df 100644 --- a/packages/middleware/express/tsconfig.json +++ b/packages/middleware/express/tsconfig.json @@ -18,6 +18,9 @@ ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ] } } diff --git a/packages/middleware/hono/tsconfig.json b/packages/middleware/hono/tsconfig.json index 01f4447bdb..aa149835df 100644 --- a/packages/middleware/hono/tsconfig.json +++ b/packages/middleware/hono/tsconfig.json @@ -18,6 +18,9 @@ ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ] } } diff --git a/packages/middleware/node/tsconfig.json b/packages/middleware/node/tsconfig.json index 1471f487d7..6c35df5027 100644 --- a/packages/middleware/node/tsconfig.json +++ b/packages/middleware/node/tsconfig.json @@ -17,6 +17,9 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"] } } diff --git a/packages/server/src/ext/skills/catalogBuild.ts b/packages/server/src/ext/skills/catalogBuild.ts index 3f201c9864..dd08ba39d8 100644 --- a/packages/server/src/ext/skills/catalogBuild.ts +++ b/packages/server/src/ext/skills/catalogBuild.ts @@ -1,7 +1,7 @@ import type { Skill, SkillResourceEntry } from '@modelcontextprotocol/core/ext/skills'; import { DIRECTORY_MIME_TYPE } from '@modelcontextprotocol/core/ext/skills'; import type { Resource } from '@modelcontextprotocol/core-internal'; -import { buildSkillEntry, compareUris, isRoundTripUri, joinUri, mimeTypeFor } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, compareUris, isRoundTripUri, joinUri, mimeTypeFor } from '@modelcontextprotocol/core-internal/ext/skills'; import { SkillCatalogError } from './errors'; import type { SkillFileEntry, SkillFiles, SkillWarning } from './types'; @@ -213,8 +213,19 @@ async function buildOne( return { dir, skill, files: catalogFiles, manifestSize, inside }; } +/** Freezes `value` and every object and array inside it. */ +function deepFreeze(value: T): T { + if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) { + Object.freeze(value); + for (const item of Object.values(value)) deepFreeze(item); + } + return value; +} + function assemble(built: readonly BuiltSkill[], tree: readonly TreeEntry[], uriOf: UriOf): CatalogSnapshot { - const ordered = built.map(item => item.skill).toSorted((a, b) => compareUris(a.uri, b.uri)); + // Entries are served as they are (trusted, never re-validated) and handed to callers by `entries`: frozen, so an + // edit (say, of a description for display) throws instead of changing what clients receive. + const ordered = Object.freeze(built.map(item => deepFreeze(item.skill)).toSorted((a, b) => compareUris(a.uri, b.uri))); const files = new Map(); const dirPaths = new Set(); for (const item of built) { diff --git a/packages/server/src/ext/skills/catalogSource.ts b/packages/server/src/ext/skills/catalogSource.ts index c9735149b3..2894f24c18 100644 --- a/packages/server/src/ext/skills/catalogSource.ts +++ b/packages/server/src/ext/skills/catalogSource.ts @@ -1,11 +1,5 @@ -import { - canonicalUri, - decodeUtf8Lossless, - ProtocolError, - ProtocolErrorCode, - ResourceNotFoundError, - sha256Digest -} from '@modelcontextprotocol/core-internal'; +import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError } from '@modelcontextprotocol/core-internal'; +import { canonicalUri, decodeUtf8Lossless, sha256Digest } from '@modelcontextprotocol/core-internal/ext/skills'; import type { CatalogSnapshot } from './catalogBuild'; import { trustedSources } from './internal'; diff --git a/packages/server/src/ext/skills/extension.ts b/packages/server/src/ext/skills/extension.ts index 3e45cedfc0..03fcb4adad 100644 --- a/packages/server/src/ext/skills/extension.ts +++ b/packages/server/src/ext/skills/extension.ts @@ -12,15 +12,12 @@ import { import type { CacheHint, ListResourcesResult, ReadResourceResult, ServerContext } from '@modelcontextprotocol/core-internal'; import { assertValidCacheHint, - canonicalUri, - encodeBase64, isModernProtocolVersion, - joinUri, ProtocolError, ProtocolErrorCode, - ResourceNotFoundError, - skillEntryProblems + ResourceNotFoundError } from '@modelcontextprotocol/core-internal'; +import { canonicalUri, encodeBase64, joinUri, skillEntryProblems } from '@modelcontextprotocol/core-internal/ext/skills'; import type { McpServer } from '../../server/mcp'; import { ResourceTemplate } from '../../server/mcp'; @@ -55,6 +52,20 @@ function toContents(uri: string, result: SkillReadResult): ReadResourceResult['c return 'text' in result ? { uri, ...mimeType, text: result.text } : { uri, ...mimeType, blob: encodeBase64(result.bytes) }; } +/** Servers any skills extension is attached to: one per server (SEP-3371 rejects a duplicate extension identifier). */ +const skillsServers = new WeakSet(); + +/** + * What runs after `attach` registered everything, by extension (`SkillCatalog` tracks the servers it notifies). + * Package-internal and off the class surface: SEP-3371 registration (`extensions: [skills]`) will not go through + * `attach`, so a subclass must not be able to hook it. + */ +const afterAttach = new WeakMap void>(); + +const ONE_PER_SERVER = + 'another skills extension is already attached to this server; attach one per server ' + + '(to serve several folders or sources, combine them behind one SkillFiles adapter or SkillSource)'; + /** * The MCP Skills extension (SEP-2640) for one catalog of skills. One instance * serves any number of servers: call {@linkcode SkillsExtension.attach} on each @@ -94,17 +105,21 @@ export class SkillsExtension { } /** - * Serves these skills on `mcp`: declares the capability, registers the - * skill resource template and the skills methods. Must run before - * connecting. Calling it again for the same server does nothing. + * Serves these skills on `mcp`: registers the skill resource template and + * the skills methods, then declares the capability. Must run before + * connecting. Calling it again for the same server does nothing. One + * skills extension per server: attaching another one throws and leaves the + * server as it was. */ attach(mcp: McpServer): void { if (this.#attached.has(mcp)) return; if (mcp.isConnected()) throw new Error('attach skills before connecting the server'); + if (skillsServers.has(mcp)) throw new Error(ONE_PER_SERVER); const server = mcp.server; const directoryRead = this.#source.readDirectory !== undefined; - server.registerCapabilities({ extensions: { [SKILLS_EXTENSION_ID]: directoryRead ? { directoryRead: true } : {} } }); const list = this.#source.listResources === undefined ? undefined : (ctx: ServerContext) => this.#listResources(ctx); + // All or nothing: `registerResource` rejects a taken template name before it changes anything, and nothing + // after it can throw. The capability is declared last, so a server never advertises skills it does not serve. mcp.registerResource( SKILLS_EXTENSION_ID, new ResourceTemplate(`${this.#baseRoot}{+path}`, { list }), @@ -122,13 +137,12 @@ export class SkillsExtension { this.#readDirectory(server, params.uri, params.cursor, ctx) ); } + server.registerCapabilities({ extensions: { [SKILLS_EXTENSION_ID]: directoryRead ? { directoryRead: true } : {} } }); this.#attached.add(mcp); - this.afterAttach(mcp); + skillsServers.add(mcp); + afterAttach.get(this)?.(mcp); } - /** Called once per server after {@linkcode SkillsExtension.attach} registered everything. */ - protected afterAttach(_mcp: McpServer): void {} - #cacheFields(server: Server): { ttlMs?: number; cacheScope?: 'public' | 'private' } { return isModernProtocolVersion(server.getNegotiatedProtocolVersion() ?? '') ? { ...this.#cache } : {}; } @@ -231,7 +245,7 @@ function listingKey(snapshot: CatalogSnapshot): string { return JSON.stringify(snapshot.listed.map(resource => [resource.uri, resource.name, resource.description])); } -function diffSnapshots(previous: CatalogSnapshot, next: CatalogSnapshot): SkillCatalogChanges { +function diffSnapshots(previous: CatalogSnapshot, next: CatalogSnapshot): Omit { const same = (uri: string): boolean => JSON.stringify(previous.skills.get(uri)) === JSON.stringify(next.skills.get(uri)); return { added: [...next.skills.keys()].filter(uri => !previous.skills.has(uri)), @@ -256,6 +270,7 @@ export class SkillCatalog extends SkillsExtension { this.#state = init.state; this.#files = init.files; this.#build = init.build; + afterAttach.set(this, mcp => this.#track(mcp)); } static override async load(files: SkillFiles, options: SkillCatalogOptions = {}): Promise { @@ -281,7 +296,7 @@ export class SkillCatalog extends SkillsExtension { }); } - /** The skills this catalog serves, sorted by URI. */ + /** The skills this catalog serves, sorted by URI. Frozen (deeply): they are exactly what clients receive. */ get entries(): readonly Skill[] { return this.#state.current.ordered; } @@ -289,9 +304,16 @@ export class SkillCatalog extends SkillsExtension { /** * Scans the storage again and swaps the new catalog in at once. All or * nothing: on failure the old catalog keeps serving. Overlapping calls run - * one after another. Sends `resources/list_changed` to connected servers - * when skills are added or removed, or a `SKILL.md`'s name or description - * changes. + * one after another. + * + * When skills are added or removed, or a `SKILL.md`'s name or description + * changes, the result has `listChanged: true` and `resources/list_changed` + * goes out through every attached server that is still connected (stdio, + * stateful 2025-11-25 HTTP sessions). Servers that `createMcpHandler` builds per + * request are closed by then, so publish the change on the handler: + * `if (changes.listChanged) handler.notify.resourcesChanged()`. Where the + * runtime has no `WeakRef` (Cloudflare Workers with a compatibility date + * before 2025-05-05), attached servers are not tracked and nothing is sent. */ reload(): Promise { const run = this.#queue.then(() => this.#reloadNow()); @@ -302,7 +324,10 @@ export class SkillCatalog extends SkillsExtension { return run; } - protected override afterAttach(mcp: McpServer): void { + /** Remembers `mcp`, weakly, for `resources/list_changed` after a reload. */ + #track(mcp: McpServer): void { + // Without WeakRef, holding servers strongly would keep every per-request server alive; track none instead. + if (typeof WeakRef !== 'function') return; if (this.#servers.size >= PRUNE_THRESHOLD) { for (const reference of this.#servers) if (reference.deref() === undefined) this.#servers.delete(reference); } @@ -313,8 +338,9 @@ export class SkillCatalog extends SkillsExtension { const previous = this.#state.current; const next = await buildCatalogSnapshot(this.#files, this.#build); this.#state.current = next; - if (listingKey(previous) !== listingKey(next)) this.#notify(); - return diffSnapshots(previous, next); + const listChanged = listingKey(previous) !== listingKey(next); + if (listChanged) this.#notify(); + return { ...diffSnapshots(previous, next), listChanged }; } /** diff --git a/packages/server/src/ext/skills/files.ts b/packages/server/src/ext/skills/files.ts index 3a16b8276e..d24c341b21 100644 --- a/packages/server/src/ext/skills/files.ts +++ b/packages/server/src/ext/skills/files.ts @@ -1,4 +1,4 @@ -import { encodeUtf8 } from '@modelcontextprotocol/core-internal'; +import { encodeUtf8 } from '@modelcontextprotocol/core-internal/ext/skills'; import type { SkillFileEntry, SkillFiles } from './types'; diff --git a/packages/server/src/ext/skills/fsPath.ts b/packages/server/src/ext/skills/fsPath.ts new file mode 100644 index 0000000000..6e294058ec --- /dev/null +++ b/packages/server/src/ext/skills/fsPath.ts @@ -0,0 +1,27 @@ +import { splitRelativePath } from './files'; + +/** The parts of `node:path` the containment rule needs: `path`, `path.win32` and `path.posix` all fit. */ +export interface PathRules { + readonly sep: string; + join(...paths: string[]): string; + relative(from: string, to: string): string; + isAbsolute(path: string): boolean; +} + +const escapes = (relative: string): TypeError => + new TypeError(`path must be a relative path that stays inside the skills folder (got ${JSON.stringify(relative)})`); + +/** + * Where `relative` (an adapter path: `/`-separated, `''` for the root) is under `base`, by the platform's `rules`. + * Throws a `TypeError` for any path that could leave `base`: a `..` segment, also one written with the platform's + * own separator (`..\x` on Windows), or a result outside `base`. + */ +export function fsPathWithin(rules: PathRules, base: string, relative: string): string { + if (relative === '') return base; + const segments = splitRelativePath(relative, 'path'); + if (segments.some(segment => segment.split(rules.sep).includes('..'))) throw escapes(relative); + const full = rules.join(base, ...segments); + const inside = rules.relative(base, full); + if (inside === '..' || inside.startsWith(`..${rules.sep}`) || rules.isAbsolute(inside)) throw escapes(relative); + return full; +} diff --git a/packages/server/src/ext/skills/node.ts b/packages/server/src/ext/skills/node.ts index 0a963fb2b4..12cb203f23 100644 --- a/packages/server/src/ext/skills/node.ts +++ b/packages/server/src/ext/skills/node.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import type { SkillCatalog } from './extension'; import { SkillsExtension } from './extension'; -import { splitRelativePath } from './files'; +import { fsPathWithin } from './fsPath'; import type { SkillCatalogOptions, SkillFileEntry, SkillFiles } from './types'; function kindOf(entry: Dirent): SkillFileEntry['kind'] { @@ -25,10 +25,12 @@ function kindOf(entry: Dirent): SkillFileEntry['kind'] { * contains one. `read` refuses anything that is not a regular file, and a * symlink as the final path component where the platform has `O_NOFOLLOW`; * symlinked folders earlier in a path passed to it directly are not checked. + * Both throw a `TypeError` for a path that could leave `root` (a `..` + * segment, also `..\` on Windows). */ export function nodeSkillFiles(root: string): SkillFiles { const base = path.resolve(root); - const toFsPath = (relative: string): string => (relative === '' ? base : path.join(base, ...splitRelativePath(relative, 'path'))); + const toFsPath = (relative: string): string => fsPathWithin(path, base, relative); return { async list(dir) { const entries = await readdir(toFsPath(dir), { withFileTypes: true }); diff --git a/packages/server/src/ext/skills/paging.ts b/packages/server/src/ext/skills/paging.ts index b73d932914..ae722ad098 100644 --- a/packages/server/src/ext/skills/paging.ts +++ b/packages/server/src/ext/skills/paging.ts @@ -1,12 +1,5 @@ -import { - compareUris, - decodeBase64, - decodeUtf8Lossless, - encodeBase64, - encodeUtf8, - ProtocolError, - ProtocolErrorCode -} from '@modelcontextprotocol/core-internal'; +import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core-internal'; +import { compareUris, decodeBase64, decodeUtf8Lossless, encodeBase64, encodeUtf8 } from '@modelcontextprotocol/core-internal/ext/skills'; const CURSOR_PREFIX = 'after:'; diff --git a/packages/server/src/ext/skills/skillEntry.ts b/packages/server/src/ext/skills/skillEntry.ts index c6d0fd9b0e..f5f29661b6 100644 --- a/packages/server/src/ext/skills/skillEntry.ts +++ b/packages/server/src/ext/skills/skillEntry.ts @@ -1,5 +1,5 @@ import type { Skill } from '@modelcontextprotocol/core/ext/skills'; -import { buildSkillEntry, encodeUtf8, joinUri } from '@modelcontextprotocol/core-internal'; +import { buildSkillEntry, encodeUtf8, joinUri } from '@modelcontextprotocol/core-internal/ext/skills'; import { SkillCatalogError } from './errors'; import { splitRelativePath } from './files'; diff --git a/packages/server/src/ext/skills/types.ts b/packages/server/src/ext/skills/types.ts index 57b3d969a5..69ecd5540e 100644 --- a/packages/server/src/ext/skills/types.ts +++ b/packages/server/src/ext/skills/types.ts @@ -69,4 +69,9 @@ export interface SkillCatalogChanges { added: string[]; removed: string[]; changed: string[]; + /** + * Whether what clients list changed: a skill added or removed, or a `SKILL.md`'s name or description. When + * `true`, tell clients (`resources/list_changed`); behind `createMcpHandler`, call `handler.notify.resourcesChanged()`. + */ + listChanged: boolean; } diff --git a/packages/server/test/ext/skills/catalog.test.ts b/packages/server/test/ext/skills/catalog.test.ts index 120dca559f..1d50766b9f 100644 --- a/packages/server/test/ext/skills/catalog.test.ts +++ b/packages/server/test/ext/skills/catalog.test.ts @@ -162,6 +162,33 @@ describe('SkillCatalog serving', () => { for (const server of servers) catalog.attach(server); for (const server of servers) expect(await resultOf(server, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); }); + + it('hands out frozen entries: an edit throws and never changes what is served', async () => { + const { catalog, mcp } = await loaded(); + const refunds = catalog.entries.find(skill => skill.uri === REFUNDS)!; + expect(() => { + (refunds.frontmatter as Record)['description'] = 'edited for display'; + }).toThrow(TypeError); + expect(() => { + (refunds.resources as SkillResourceEntry[])[0]!.digest = `sha256:${'0'.repeat(64)}`; + }).toThrow(TypeError); + expect(() => (refunds.resources as SkillResourceEntry[]).pop()).toThrow(TypeError); + expect(() => (catalog.entries as Skill[]).pop()).toThrow(TypeError); + expect(await resultOf(mcp, 'skills/get', { uri: REFUNDS })).toMatchObject({ + skill: { frontmatter: { description: 'Refund workflow.' } } + }); + expect(catalog.entries).toHaveLength(3); + }); + + it('freezes nested frontmatter too', async () => { + const skillMd = '---\nname: owned\ndescription: d\nmetadata:\n owner: billing\ntags: [a, b]\n---\n'; + const catalog = await SkillsExtension.load(memorySkillFiles({ 'owned/SKILL.md': skillMd })); + const frontmatter = catalog.entries[0]!.frontmatter as unknown as { metadata: Record; tags: string[] }; + expect(() => { + frontmatter.metadata['owner'] = 'someone else'; + }).toThrow(TypeError); + expect(() => frontmatter.tags.push('c')).toThrow(TypeError); + }); }); describe('SkillCatalog.reload', () => { @@ -170,10 +197,42 @@ describe('SkillCatalog.reload', () => { delete files['git-workflow/SKILL.md']; delete files['git-workflow/references/REVIEW.md']; files['acme/billing/refunds/templates/Purchase Order.md'] = '# PO v2\n'; - expect(await catalog.reload()).toEqual({ added: [], removed: [GIT], changed: [REFUNDS] }); + expect(await catalog.reload()).toEqual({ added: [], removed: [GIT], changed: [REFUNDS], listChanged: true }); expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION]); }); + it('reports listChanged only when what clients list changes', async () => { + const { catalog, files } = await loaded(); + expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [], listChanged: false }); + files['git-workflow/references/REVIEW.md'] = '# Review v2\n'; + expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [GIT], listChanged: false }); + files['git-workflow/SKILL.md'] = GIT_SKILL_MD.replace('Branching and review conventions.', 'Branching, review and release.'); + expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [GIT], listChanged: true }); + files['aaa/SKILL.md'] = NEW_SKILL; + expect(await catalog.reload()).toMatchObject({ added: ['skill://aaa/SKILL.md'], listChanged: true }); + }); + + it('attaches and reloads where the runtime has no WeakRef, tracking no servers (Workers before 2025-05-05)', async () => { + const { catalog, files } = await loaded(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'WeakRef')!; + Reflect.deleteProperty(globalThis, 'WeakRef'); + try { + expect(typeof WeakRef).toBe('undefined'); + const mcp = newServer(); + expect(() => catalog.attach(mcp)).not.toThrow(); + expect(await resultOf(mcp, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); + const notify = vi.spyOn(mcp.server, 'sendResourceListChanged'); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await mcp.connect(serverSide); + files['aaa/SKILL.md'] = NEW_SKILL; + expect(await catalog.reload()).toMatchObject({ added: ['skill://aaa/SKILL.md'], listChanged: true }); + expect(notify).not.toHaveBeenCalled(); + await mcp.close(); + } finally { + Object.defineProperty(globalThis, 'WeakRef', descriptor); + } + }); + it('keeps serving the old catalog when a reload fails', async () => { const { catalog, files, mcp } = await loaded({ strict: true }); files['git-workflow/SKILL.md'] = '---\nname: renamed\ndescription: d\n---\n'; @@ -200,10 +259,10 @@ describe('SkillCatalog.reload', () => { const [, serverSide] = InMemoryTransport.createLinkedPair(); await mcp.connect(serverSide); files['git-workflow/references/REVIEW.md'] = '# Review v2\n'; - await catalog.reload(); + expect(await catalog.reload()).toMatchObject({ listChanged: false }); expect(notify).not.toHaveBeenCalled(); files['aaa/SKILL.md'] = NEW_SKILL; - await catalog.reload(); + expect(await catalog.reload()).toMatchObject({ listChanged: true }); expect(notify).toHaveBeenCalledTimes(1); expect(skipped).not.toHaveBeenCalled(); await mcp.close(); diff --git a/packages/server/test/ext/skills/extension.test.ts b/packages/server/test/ext/skills/extension.test.ts index dede6f5e45..57fed5fcb0 100644 --- a/packages/server/test/ext/skills/extension.test.ts +++ b/packages/server/test/ext/skills/extension.test.ts @@ -5,7 +5,9 @@ import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; import { describe, expect, it } from 'vitest'; import type { SkillSource, SkillWarning } from '../../../src/ext/skills/index'; -import { SkillCatalogError, skillEntry, SkillsExtension } from '../../../src/ext/skills/index'; +import { memorySkillFiles, SkillCatalogError, skillEntry, SkillsExtension } from '../../../src/ext/skills/index'; +import { ResourceTemplate } from '../../../src/server/mcp'; +import { GIT, GIT_SKILL_MD } from './fixtures'; import { errorOf, LEGACY, newServer, resultOf } from './harness'; const digest = `sha256:${'a'.repeat(64)}`; @@ -192,6 +194,54 @@ describe('attach', () => { extension.attach(second); expect(await resultOf(first, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ skill: gitWorkflow }); expect(await resultOf(second, 'skills/get', { uri: gitWorkflow.uri })).toMatchObject({ skill: gitWorkflow }); + expect(first.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': {} }); + }); + + it('refuses a second skills extension on a server, leaving the first one serving with its capability unchanged', async () => { + const mcp = newServer(); + const catalog = await SkillsExtension.load(memorySkillFiles({ 'git-workflow/SKILL.md': GIT_SKILL_MD })); + catalog.attach(mcp); + const declared = { 'io.modelcontextprotocol/skills': { directoryRead: true } }; + expect(mcp.server.getCapabilities().extensions).toEqual(declared); + // A source without readDirectory would declare `{}`: the probe that used to overwrite `directoryRead`. + const second = new SkillsExtension({ source: sourceOf() }); + expect(() => second.attach(mcp)).toThrow(/another skills extension is already attached/); + expect(() => second.attach(mcp)).toThrow(/another skills extension is already attached/); + expect(mcp.server.getCapabilities().extensions).toEqual(declared); + expect(await resultOf(mcp, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); + expect(await resultOf(mcp, 'resources/directory/read', { uri: 'skill://git-workflow' })).toMatchObject({ + resources: [{ name: 'SKILL.md' }] + }); + // The refused extension still attaches to a server of its own. + const own = newServer(); + second.attach(own); + expect(own.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': {} }); + }); + + it('declares nothing when the template name is already taken', () => { + const mcp = newServer(); + mcp.registerResource( + 'io.modelcontextprotocol/skills', + new ResourceTemplate('app://{+path}', { list: undefined }), + {}, + async uri => ({ + contents: [{ uri: uri.href, text: 'app' }] + }) + ); + expect(() => new SkillsExtension({ source: sourceOf() }).attach(mcp)).toThrow(/already registered/); + expect(mcp.server.getCapabilities().extensions?.['io.modelcontextprotocol/skills']).toBeUndefined(); + }); + + it('has no attach hook a subclass can override', () => { + let calls = 0; + class Hooked extends SkillsExtension { + afterAttach(): void { + calls++; + } + } + new Hooked({ source: sourceOf() }).attach(newServer()); + expect(calls).toBe(0); + expect('afterAttach' in SkillsExtension.prototype).toBe(false); }); it('throws after the server connected', async () => { diff --git a/packages/server/test/ext/skills/node.test.ts b/packages/server/test/ext/skills/node.test.ts index 5da9299862..7235b482cf 100644 --- a/packages/server/test/ext/skills/node.test.ts +++ b/packages/server/test/ext/skills/node.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { fsPathWithin } from '../../../src/ext/skills/fsPath'; import type { SkillWarning } from '../../../src/ext/skills/index'; import { memorySkillFiles, SkillCatalogError, SkillsExtension } from '../../../src/ext/skills/index'; import { loadSkills, nodeSkillFiles } from '../../../src/ext/skills/node'; @@ -55,6 +56,36 @@ describe('nodeSkillFiles', () => { }); }); +// The containment rule nodeSkillFiles applies, run with each platform's path rules so every OS tests both. +describe('fsPathWithin', () => { + it('rejects a Windows ..\\ escape by Windows rules', () => { + const base = String.raw`C:\skills`; + // What the adapter used to do: `path.join` resolves the backslash `..` and leaves the root. + expect(path.win32.join(base, String.raw`..\secret`)).toBe(String.raw`C:\secret`); + for (const escape of [String.raw`..\secret`, String.raw`git-workflow\..\..\secret`, String.raw`git-workflow/..\..\secret`]) { + expect(() => fsPathWithin(path.win32, base, escape)).toThrow(TypeError); + } + expect(() => fsPathWithin(path.win32, base, String.raw`git-workflow\..\SKILL.md`)).toThrow(TypeError); + expect(() => fsPathWithin(path.win32, base, '../secret')).toThrow(TypeError); + }); + + it('keeps paths inside the root by Windows rules', () => { + const base = String.raw`C:\skills`; + expect(fsPathWithin(path.win32, base, '')).toBe(base); + expect(fsPathWithin(path.win32, base, 'git-workflow/references/REVIEW.md')).toBe( + String.raw`C:\skills\git-workflow\references\REVIEW.md` + ); + expect(fsPathWithin(path.win32, base, '..notes/a.md')).toBe(String.raw`C:\skills\..notes\a.md`); + }); + + it('treats a backslash as part of a name by POSIX rules', () => { + expect(fsPathWithin(path.posix, '/skills', String.raw`..\secret`)).toBe(String.raw`/skills/..\secret`); + expect(fsPathWithin(path.posix, '/skills', 'git-workflow/SKILL.md')).toBe('/skills/git-workflow/SKILL.md'); + expect(() => fsPathWithin(path.posix, '/skills', '../secret')).toThrow(TypeError); + expect(() => fsPathWithin(path.posix, '/skills', 'a/../../secret')).toThrow(TypeError); + }); +}); + describe('loadSkills', () => { it('builds the same entries as the in-memory adapter', async () => { const fromDisk = await loadSkills(root); @@ -99,7 +130,7 @@ describe('loadSkills', () => { await writeFile(path.join(root, 'git-workflow', 'references', 'REVIEW.md'), '# Review v2\n'); const uri = 'skill://git-workflow/references/REVIEW.md'; expect(await errorOf(mcp, 'resources/read', { uri })).toMatchObject({ code: -32_603 }); - expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [GIT] }); + expect(await catalog.reload()).toEqual({ added: [], removed: [], changed: [GIT], listChanged: false }); expect(await resultOf(mcp, 'resources/read', { uri })).toMatchObject({ contents: [{ uri, text: '# Review v2\n' }] }); }); }); diff --git a/packages/server/test/server/barrelClean.test.ts b/packages/server/test/server/barrelClean.test.ts index cbf2300ddb..72fc4221bf 100644 --- a/packages/server/test/server/barrelClean.test.ts +++ b/packages/server/test/server/barrelClean.test.ts @@ -17,6 +17,10 @@ const ROOT_VALIDATOR_EXPORTS = ['AjvJsonSchemaValidator', 'CfWorkerJsonSchemaVal // Static (`from "node:x"`), side-effect (`import "node:x"`, what rolldown keeps for an external whose bindings were // tree-shaken) and dynamic `node:` imports. Only `.mjs` files are scanned, so `require(` is not matched. const NODE_BUILTIN_IMPORT = /\b(?:from|import)\s*["']node:|\bimport\s*\(\s*["']node:/; +// The skills extension's shared schemas: a static, side-effect or dynamic import of the external subpath. +const CORE_SKILLS_IMPORT = /\b(?:from|import)\s*\(?\s*["']@modelcontextprotocol\/core\/ext\/skills["']/; +// The skills extension's own code (core-internal's `ext/skills`): the frontmatter parser and the entry builder. +const SKILLS_CODE = /\b(?:FrontmatterError|parseFrontmatterYaml|buildSkillEntry|skillEntryProblems)\b/; /** * An ESM entry and every chunk it imports, following relative `from` and side-effect `import` specifiers, both @@ -36,6 +40,21 @@ function moduleGraphOf(entryPath: string): string[] { return [...visited]; } +/** A declaration entry and every declaration chunk it imports (a `.d.mts` file names its siblings as `.mjs`). */ +function declarationGraphOf(entryPath: string): string[] { + const visited = new Set(); + const queue = [entryPath]; + while (queue.length > 0) { + const file = queue.shift()!; + if (visited.has(file)) continue; + visited.add(file); + for (const m of readFileSync(file, 'utf8').matchAll(/\b(?:from|import)\s*["'](\.{1,2}\/[^"']+?)\.mjs["']/g)) { + queue.push(join(dirname(file), `${m[1]!}.d.mts`)); + } + } + return [...visited]; +} + function rootExportBlockOf(content: string): string { const blocks = content.match(/(?:^|\n)export \{[\s\S]*?\};/g); expect(blocks).toBeTruthy(); @@ -129,4 +148,30 @@ describe('@modelcontextprotocol/server root and ext/skills are runtime-neutral', /\b(?:SkillsExtension|SkillCatalog|loadSkills|nodeSkillFiles)\b/ ); }); + + test('dist/index.mjs and every chunk it imports carry no skills code and no @modelcontextprotocol/core/ext/skills import', () => { + // Users who never import ext/skills must not load (or bundle) the frontmatter parser or the skills schemas. + for (const file of moduleGraphOf(join(distDir, 'index.mjs'))) { + const content = readFileSync(file, 'utf8'); + expect({ file, content }).not.toEqual(expect.objectContaining({ content: expect.stringMatching(CORE_SKILLS_IMPORT) })); + expect({ file, content }).not.toEqual(expect.objectContaining({ content: expect.stringMatching(SKILLS_CODE) })); + } + }); + + test('the ext/skills entry graph is where the skills code and the core/ext/skills import live (guards the patterns)', () => { + const graph = moduleGraphOf(join(distDir, 'ext/skills/index.mjs')).map(file => readFileSync(file, 'utf8')); + expect(graph.some(content => CORE_SKILLS_IMPORT.test(content))).toBe(true); + expect(graph.some(content => SKILLS_CODE.test(content))).toBe(true); + }); + + test('the ext/skills declarations give subclasses no attach hook to override', () => { + // SEP-3371 registration (`extensions: [skills]`) will not go through attach(), so no hook may be public API. + const graph = declarationGraphOf(join(distDir, 'ext/skills/index.d.mts')); + expect(graph.some(file => /\bdeclare class SkillCatalog\b/.test(readFileSync(file, 'utf8')))).toBe(true); + for (const file of graph) { + expect({ file, content: readFileSync(file, 'utf8') }).not.toEqual( + expect.objectContaining({ content: expect.stringMatching(/\bafterAttach\b/) }) + ); + } + }); }); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index ab272e9708..54f3389a5f 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -11,6 +11,9 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/validators/ajv": [ "./node_modules/@modelcontextprotocol/core-internal/src/validators/ajvProvider.ts" ], diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index 78bfcb33a7..06137a4560 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ '@modelcontextprotocol/core/ext/skills': ['../core/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], + '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index 676fe7e1c5..01cfe3ef37 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -15,6 +15,9 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json index fbf706b21a..ac11a91396 100644 --- a/test/e2e/tsconfig.json +++ b/test/e2e/tsconfig.json @@ -15,6 +15,9 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/validators/cfWorker": [ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], diff --git a/test/helpers/tsconfig.json b/test/helpers/tsconfig.json index 5e6d0ea6d0..e6108eaf1f 100644 --- a/test/helpers/tsconfig.json +++ b/test/helpers/tsconfig.json @@ -15,6 +15,9 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] } } diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index 150a938ccc..ad42938e31 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -15,6 +15,7 @@ import * as os from 'node:os'; import path from 'node:path'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const READINESS_TIMEOUT_MS = 60_000; @@ -27,6 +28,9 @@ const SHUTDOWN_GRACE_MS = 5000; */ const SERVER_VERSION_NONCE = randomUUID(); +/** The skill the worker serves on `/skills`, from memory (Workers have no file system). */ +const RELEASE_NOTES_MD = '---\nname: release-notes\ndescription: Write release notes from merged pull requests.\n---\n# Release notes\n'; + /** * The workspace's own wrangler installation (pinned by pnpm-lock). Running it directly — * instead of `npm install`-ing wrangler into the temp project and going through `npx` — @@ -297,13 +301,18 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => { }; fs.writeFileSync(path.join(tempDir, 'wrangler.jsonc'), JSON.stringify(wranglerConfig, null, 2)); - // Write server source + // Write server source. `/skills` also serves a skills catalog, loaded once at module scope + // and attached to each per-request server (the shape the server guide recommends for + // Workers); `/runtime` reports the globals the skills extension feature-detects. const serverSource = ` import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; import { z } from 'zod'; +const skills = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': ${JSON.stringify(RELEASE_NOTES_MD)} })); + // A stateless transport serves one request: connect a new pair for each. -async function connectPair() { +async function connectPair(withSkills) { const server = new McpServer({ name: "test-server", version: "${SERVER_VERSION_NONCE}" }); server.registerTool("greet", { @@ -312,6 +321,7 @@ async function connectPair() { }, async ({ name }) => ({ content: [{ type: "text", text: "Hello, " + name + "!" }] })); + if (withSkills) skills.attach(server); const transport = new WebStandardStreamableHTTPServerTransport(); await server.connect(transport); @@ -320,7 +330,11 @@ async function connectPair() { export default { fetch: async (request) => { - const transport = await connectPair(); + const { pathname } = new URL(request.url); + if (pathname === '/runtime') { + return new Response(JSON.stringify({ weakRef: typeof WeakRef }), { headers: { 'content-type': 'application/json' } }); + } + const transport = await connectPair(pathname === '/skills'); return transport.handleRequest(request); } }; @@ -378,4 +392,24 @@ export default { await client.close(); }, 30_000); + + it('serves a skills catalog attached per request, on a compatibility date without WeakRef', async () => { + // The premise: `enable_weak_ref` is on by default only from compatibility date 2025-05-05. + const response = await fetch(`http://127.0.0.1:${port}/runtime`); + expect(await response.json()).toEqual({ weakRef: 'undefined' }); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/skills`))); + const skills = new SkillsClient(client); + expect(skills.supported).toBe(true); + + const { skills: entries, invalid } = await skills.list(); + expect(invalid).toEqual([]); + expect(entries.map(skill => skill.uri)).toEqual(['skill://release-notes/SKILL.md']); + const manifest = await skills.read(entries[0]!); + expect(manifest).toMatchObject({ verified: true, text: RELEASE_NOTES_MD }); + expect(manifest.frontmatter).toEqual({ name: 'release-notes', description: 'Write release notes from merged pull requests.' }); + + await client.close(); + }, 30_000); }); diff --git a/test/integration/test/skills/skillsEndToEnd.test.ts b/test/integration/test/skills/skillsEndToEnd.test.ts index 2b924f6829..edbc6c6c04 100644 --- a/test/integration/test/skills/skillsEndToEnd.test.ts +++ b/test/integration/test/skills/skillsEndToEnd.test.ts @@ -4,7 +4,7 @@ * (`createMcpHandler`, one server per request) and stdio (a child process), on * both protocol versions. */ -import { rm, writeFile } from 'node:fs/promises'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; @@ -13,7 +13,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import type { SkillCatalog } from '@modelcontextprotocol/server/ext/skills'; import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { ESCALATION, GIT, PURCHASE_ORDER, REFUNDS, writeSkillTree } from './skillTree'; @@ -150,10 +150,50 @@ describe('one catalog behind per-request HTTP servers', () => { const before = await skills.get(GIT); await writeFile(path.join(editable, 'git-workflow', 'references', 'REVIEW.md'), '# Review v2\n'); await expect(skills.read(before, 'references/REVIEW.md')).rejects.toMatchObject({ code: -32_603 }); - expect(await own.reload()).toEqual({ added: [], removed: [], changed: [GIT] }); + expect(await own.reload()).toEqual({ added: [], removed: [], changed: [GIT], listChanged: false }); const after = await skills.get(GIT); expect(await skills.read(after, 'references/REVIEW.md')).toMatchObject({ text: '# Review v2\n', verified: true }); await client.close(); await rm(editable, { recursive: true, force: true }); }); + + it('tells listen streams about a listing change through handler.notify, which reload() alone cannot reach', async () => { + const editable = await writeSkillTree(); + const own = await loadSkills(editable, { onWarning: () => {} }); + const handler = createMcpHandler( + () => { + const mcp = new McpServer({ name: 'skills-http', version: '1.0.0' }); + own.attach(mcp); + return mcp; + }, + { keepAliveMs: 0 } + ); + const client = clientFor(MODERN); + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)) + }) + ); + const seen: string[] = []; + client.setNotificationHandler('notifications/resources/list_changed', () => void seen.push('resources/list_changed')); + const subscription = await client.listen({ resourcesListChanged: true }); + expect(subscription.honoredFilter).toEqual({ resourcesListChanged: true }); + + await mkdir(path.join(editable, 'aaa')); + await writeFile(path.join(editable, 'aaa', 'SKILL.md'), '---\nname: aaa\ndescription: Sorts first.\n---\n'); + const changes = await own.reload(); + expect(changes).toMatchObject({ added: ['skill://aaa/SKILL.md'], listChanged: true }); + // Every server this handler built has served its one request and closed: nothing reaches the stream. + await new Promise(resolve => setTimeout(resolve, 30)); + expect(seen).toEqual([]); + + // What docs/servers/skills.md tells servers behind createMcpHandler to do. + if (changes.listChanged) handler.notify.resourcesChanged(); + await vi.waitFor(() => expect(seen).toEqual(['resources/list_changed'])); + + await subscription.close(); + await client.close(); + await handler.close(); + await rm(editable, { recursive: true, force: true }); + }); }); diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json index 8a7f23b58a..a15fa93ad5 100644 --- a/test/integration/tsconfig.json +++ b/test/integration/tsconfig.json @@ -15,6 +15,9 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], "@modelcontextprotocol/core-internal/validators/ajv": [ "./node_modules/@modelcontextprotocol/core-internal/src/validators/ajvProvider.ts" ], From 6da07a68b34c1ebe05c656da11c1bd4f1e737f6e Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 15:57:04 +0300 Subject: [PATCH 32/45] fix(core-internal): count skill limits in characters and list each file once - The description (1-1024) and compatibility (1-500) limits count code points, as the Agent Skills reference validator does; .length counted UTF-16 units, so 600 emoji were rejected and a valid skill was skipped or filed as invalid. - A tab after a block scalar's content indent is content, as in YAML; the tab check ran on every line before parsing. Tabs still fail closed where a line is read as structure and inside a block scalar's indent, blank and comment lines included (those were read as empty or as ending the scalar). - validateSkillEntry refuses resource URIs with a query or fragment and finds duplicates by skillFileKey, so Q&A.md and Q%26A.md, or c.md and c.md#x, can no longer list one file twice (SEP-2640: "each exactly once"). - sha256Digest hashes a view over the input's buffer instead of a copy; only a SharedArrayBuffer view is copied, since WebCrypto rejects those. - Comments cut to one line and JSDoc dropped from non-exported helpers. --- .../core-internal/src/ext/skills/bytes.ts | 5 +- .../core-internal/src/ext/skills/entry.ts | 26 ++++++-- .../src/ext/skills/frontmatter.ts | 64 ++++++++----------- packages/core-internal/src/ext/skills/uri.ts | 52 +++------------ .../test/ext/skills/bytes.test.ts | 21 +++++- .../test/ext/skills/entry.test.ts | 31 +++++++++ .../skills/frontmatter.differential.test.ts | 32 +++++++++- 7 files changed, 141 insertions(+), 90 deletions(-) diff --git a/packages/core-internal/src/ext/skills/bytes.ts b/packages/core-internal/src/ext/skills/bytes.ts index 8138f31f7e..cb8e118767 100644 --- a/packages/core-internal/src/ext/skills/bytes.ts +++ b/packages/core-internal/src/ext/skills/bytes.ts @@ -34,7 +34,10 @@ export function decodeBase64(base64: string): Uint8Array { /** `sha256:` followed by the lowercase hex SHA-256 of `bytes` (WebCrypto, every runtime). */ export async function sha256Digest(bytes: Uint8Array): Promise { - const hash = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', new Uint8Array(bytes))); + const { buffer, byteOffset, byteLength } = bytes; + // WebCrypto refuses views over a SharedArrayBuffer; only those are copied. + const view = buffer instanceof ArrayBuffer ? new Uint8Array(buffer, byteOffset, byteLength) : new Uint8Array(bytes); + const hash = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', view)); let hex = ''; for (const byte of hash) hex += byte.toString(16).padStart(2, '0'); return `sha256:${hex}`; diff --git a/packages/core-internal/src/ext/skills/entry.ts b/packages/core-internal/src/ext/skills/entry.ts index 4a625f8150..c9b4117ea0 100644 --- a/packages/core-internal/src/ext/skills/entry.ts +++ b/packages/core-internal/src/ext/skills/entry.ts @@ -3,7 +3,7 @@ import { SkillSchema } from '@modelcontextprotocol/core/ext/skills'; import { decodeUtf8Lossless, sha256Digest } from './bytes'; import { FrontmatterError, parseSkillMarkdown } from './frontmatter'; -import { canonicalUri, compareUris, isWithin, lastSegmentOf, skillRootUri } from './uri'; +import { canonicalUri, compareUris, isWithin, lastSegmentOf, skillFileKey, skillRootUri } from './uri'; /** Agent Skills name rule: lowercase letters, digits and single hyphens (length checked separately). */ export const SKILL_NAME_PATTERN = /^[\da-z]+(?:-[\da-z]+)*$/; @@ -14,6 +14,13 @@ const isStringMap = (value: unknown): boolean => !Array.isArray(value) && Object.values(value as Record).every(item => typeof item === 'string'); +// Agent Skills length limits count characters (code points), not UTF-16 code units. +function countCharacters(text: string): number { + let count = 0; + for (let index = 0; index < text.length; index += text.codePointAt(index)! > 0xff_ff ? 2 : 1) count++; + return count; +} + /** Structural rules of the stable spec and Agent Skills. Empty array = valid. */ export function validateSkillEntry(skill: Skill): string[] { const problems: string[] = []; @@ -26,9 +33,12 @@ export function validateSkillEntry(skill: Skill): string[] { } else if (lastSegmentOf(root) !== name) { problems.push(`frontmatter name "${name}" must match the last path segment "${lastSegmentOf(root)}"`); } - if (description.length === 0 || description.length > 1024) problems.push('frontmatter description must be 1-1024 characters'); + if (description === '' || countCharacters(description) > 1024) problems.push('frontmatter description must be 1-1024 characters'); const compatibility = frontmatter['compatibility']; - if (compatibility !== undefined && (typeof compatibility !== 'string' || compatibility.length === 0 || compatibility.length > 500)) { + if ( + compatibility !== undefined && + (typeof compatibility !== 'string' || compatibility === '' || countCharacters(compatibility) > 500) + ) { problems.push('frontmatter compatibility must be a string of 1-500 characters'); } if (frontmatter['metadata'] !== undefined && !isStringMap(frontmatter['metadata'])) { @@ -44,8 +54,14 @@ export function validateSkillEntry(skill: Skill): string[] { problems.push(`resource "${resource.uri}" is not a valid URI`); continue; } - if (seen.has(canonical)) problems.push(`resource "${resource.uri}" is listed more than once`); - seen.add(canonical); + // A canonical URI holds a raw `?` or `#` only as a query or fragment, even an empty one. + if (/[#?]/.test(canonical)) { + problems.push(`resource "${resource.uri}" must not have a query or fragment`); + continue; + } + const key = skillFileKey(root, canonical) ?? canonical; + if (seen.has(key)) problems.push(`resource "${resource.uri}" is listed more than once`); + seen.add(key); if (canonical === self) hasSelf = true; else if (!isWithin(root, resource.uri)) problems.push(`resource "${resource.uri}" is outside the skill directory ${root}`); } diff --git a/packages/core-internal/src/ext/skills/frontmatter.ts b/packages/core-internal/src/ext/skills/frontmatter.ts index d9d01663d0..d5bf00d495 100644 --- a/packages/core-internal/src/ext/skills/frontmatter.ts +++ b/packages/core-internal/src/ext/skills/frontmatter.ts @@ -59,29 +59,23 @@ export function parseFrontmatterYaml(yaml: string, firstLine = 1): Record { - const leading = /^[\t ]*/.exec(raw)?.[0] ?? ''; - const text = raw.slice(leading.length); - const no = firstLine + index; - if (leading.includes('\t') && text !== '' && !text.startsWith('#')) { - throw new FrontmatterError('tabs cannot be used for indentation', no); - } - return { no, indent: leading.length, text, raw }; - }); + return raws.map((raw, index) => ({ no: firstLine + index, indent: /^ */.exec(raw)?.[0].length ?? 0, text: trimWhiteStart(raw), raw })); } +const TAB_INDENT = 'tabs cannot be used for indentation'; + const isSkippable = (line: Line): boolean => line.text === '' || line.text.startsWith('#'); +const isTabIndented = (line: Line): boolean => line.raw[line.indent] === '\t'; const isSequenceItem = (text: string): boolean => /^-(?:[\t ]|$)/.test(text); class Parser { @@ -92,8 +86,7 @@ class Parser { parseDocument(): Record { const first = this.peek(); if (first === undefined) return {}; - // YAML drops a byte order mark before a document's first content; reading it as part of the first key - // would differ from every YAML parser, so fail closed (a BOM before the opening `---` is fine). + // YAML drops a BOM before the first content, so reading it into the first key would differ: fail closed. if (first.text.startsWith('\uFEFF')) { throw new FrontmatterError('a byte order mark inside the frontmatter is not supported', first.no); } @@ -108,7 +101,9 @@ class Parser { private peek(): Line | undefined { while (this.index < this.lines.length && isSkippable(this.lines[this.index]!)) this.index++; - return this.lines[this.index]; + const line = this.lines[this.index]; + if (line !== undefined && isTabIndented(line)) throw new FrontmatterError(TAB_INDENT, line.no); + return line; } private parseMapping(indent: number, depth: number): Record { @@ -137,7 +132,7 @@ class Parser { const column = line.indent + 1 + (after.length - rest.length); if (rest !== '' && !rest.startsWith('#') && (isSequenceItem(rest) || matchKey(rest, line.no) !== undefined)) { // `- key: value` or `- - item`: the item is a block node that starts at `column`. - if (/^ *\t/.test(after)) throw new FrontmatterError('tabs cannot be used for indentation', line.no); + if (/^ *\t/.test(after)) throw new FrontmatterError(TAB_INDENT, line.no); this.lines[this.index] = { ...line, indent: column, text: rest }; items.push(isSequenceItem(rest) ? this.parseSequence(column, depth + 1) : this.parseMapping(column, depth + 1)); continue; @@ -198,6 +193,7 @@ class Parser { continue; } if (next.indent <= parentIndent || next.text.startsWith('#')) break; + if (isTabIndented(next)) throw new FrontmatterError(TAB_INDENT, next.no); if (commented) throw new FrontmatterError('a comment ends a plain value; quote a value that continues past it', next.no); const part = stripComment(next.text); assertPlain(part, next.no); @@ -224,21 +220,23 @@ class Parser { let leadingWidth = 0; while (this.index < this.lines.length) { const next = this.lines[this.index]!; - if (next.text === '') { - if (contentIndent === -1) leadingWidth = Math.max(leadingWidth, next.raw.length); - contents.push(contentIndent === -1 ? '' : next.raw.slice(contentIndent)); - this.index++; - continue; - } - if (contentIndent === -1) { - if (next.indent <= parentIndent) break; + const spacesOnly = next.indent === next.raw.length; + if (contentIndent === -1 && !spacesOnly && next.indent > parentIndent) { contentIndent = next.indent; if (leadingWidth > contentIndent) { throw new FrontmatterError('a leading blank line is indented more than the block scalar content', next.no); } } - if (next.indent < contentIndent) break; - contents.push(next.raw.slice(contentIndent)); + if (contentIndent !== -1 && next.indent >= contentIndent) { + contents.push(next.raw.slice(contentIndent)); + } else if (spacesOnly) { + if (contentIndent === -1) leadingWidth = Math.max(leadingWidth, next.indent); + contents.push(''); + } else if (isTabIndented(next)) { + throw new FrontmatterError(TAB_INDENT, next.no); + } else { + break; + } this.index++; } return foldBlock(contents, match[1] === '>', match[2] ?? ''); @@ -306,16 +304,11 @@ function expectOnlyComment(after: string, no: number): void { throw new FrontmatterError('unexpected text after the value', no); } -// YAML white space is only space and tab (YAML 1.2 `s-white`). `String.prototype.trim` and friends also strip -// U+00A0, U+3000, U+2028, U+FEFF and other Unicode spaces, which YAML keeps as content: using them here would read -// `description: a` as "a" where every YAML 1.2 parser (and so every other SDK) reads "a". - -/** `text` without leading spaces and tabs. */ +// YAML white space is only space and tab; `String.prototype.trim` would also strip Unicode spaces YAML keeps. function trimWhiteStart(text: string): string { return text.slice(skipSpaces(text, 0)); } -/** `text` without trailing spaces and tabs. */ function trimWhiteEnd(text: string): string { let end = text.length; while (end > 0 && (text[end - 1] === ' ' || text[end - 1] === '\t')) end--; @@ -477,10 +470,9 @@ function readFlowKey(text: string, start: number, no: number): { key: string; en return { key: resolveKey(raw, no), end }; } -/** Whitespace and the flow indicators: after `-`, `?` or `:`, any of these makes it an indicator in a flow collection. */ +// After `-`, `?` or `:`, any of these makes it an indicator in a flow collection. const FLOW_SEPARATORS = ' \t,[]{}'; -/** Rejects a plain flow key or value whose first character YAML reads as an indicator. */ function assertFlowPlainStart(text: string, index: number, no: number): void { const char = text[index]!; if ('&*!'.includes(char)) throw new FrontmatterError('anchors, aliases and tags are not supported', no); @@ -489,7 +481,7 @@ function assertFlowPlainStart(text: string, index: number, no: number): void { } } -/** Returns where a plain flow scalar starting at `index` ends: at a flow indicator, a comment, or a `:` indicator. */ +// The end of a plain flow scalar: a flow indicator, a comment or a `:` indicator. function scanFlowPlain(text: string, index: number): number { let end = index; while (end < text.length) { @@ -516,7 +508,7 @@ function readFlowValue(text: string, start: number, no: number, depth: number): return { value: resolvePlain(raw, no), end }; } -/** Joins block scalar lines the way YAML does (algorithm as in js-yaml's readBlockScalar). */ +// Joins block scalar lines as YAML does (the algorithm of js-yaml's readBlockScalar). function foldBlock(contents: readonly string[], folded: boolean, chomping: string): string { let result = ''; let emptyLines = 0; diff --git a/packages/core-internal/src/ext/skills/uri.ts b/packages/core-internal/src/ext/skills/uri.ts index 5674e5557e..3ff7de62d3 100644 --- a/packages/core-internal/src/ext/skills/uri.ts +++ b/packages/core-internal/src/ext/skills/uri.ts @@ -1,33 +1,17 @@ -/** - * Every character a segment cannot carry as it is: WHATWG URL's path percent-encode set (controls, space, - * `"#<>?^\`{}`, DEL and non-ASCII) plus `%`, `/` and `\`, which would start an escape or split the segment. `^` is - * encoded on every runtime although older URL parsers (Node 20 and 22) leave it raw, so the published form does not - * depend on the runtime; {@linkcode skillFileKey} keys both spellings alike. - */ +// WHATWG URL's path percent-encode set, `^` included on every runtime (Node 20 and 22 leave it raw), plus `%`, `/` and `\`. const ENCODED_IN_SEGMENT = /[^\w!$&'()*+,.:;=@[\]|~-]/gu; -/** `text` with every character in {@linkcode ENCODED_IN_SEGMENT} percent-encoded as UTF-8. */ function encodePathText(text: string): string { return text.replaceAll(ENCODED_IN_SEGMENT, char => encodeURIComponent(char)); } -/** - * Percent-encodes one path segment the way WHATWG URL serialises it, so `new URL(name, root)` on a host yields the - * published URI: sub-delimiters such as `&`, `+`, `@` and `[` stay as they are, and `%`, `/`, `?`, `#` and `\` are - * always encoded. The dots of a `.` or `..` segment are encoded too; WHATWG URL still reads `%2E` as a dot segment, - * so such a URI does not survive {@linkcode isRoundTripUri} (the catalog then skips the file) rather than naming - * the directory or its parent. Throws `URIError` on a lone surrogate. - */ +/** Percent-encodes one path segment as WHATWG URL does, plus `%`, `/`, `\` and the dots of `.` and `..`; throws `URIError` on a lone surrogate. */ export function encodeSegment(segment: string): string { if (segment === '.' || segment === '..') return segment.replaceAll('.', '%2E'); return encodePathText(segment); } -/** - * `base` + `/`-joined encoded segments. `base` is `scheme://` or a URI without a trailing slash. After `scheme://` - * the first segment is the authority, where `@`, `:`, `[`, `]` and `|` would read as userinfo, a port or an IP - * literal, so it is encoded strictly with `encodeURIComponent`; path segments follow {@linkcode encodeSegment}. - */ +/** `base` (`scheme://`, or a URI without a trailing slash) + `/`-joined encoded segments; an authority is encoded strictly. */ export function joinUri(base: string, segments: readonly string[]): string { if (!base.endsWith('://')) return `${base.replace(/\/+$/, '')}/${segments.map(segment => encodeSegment(segment)).join('/')}`; const [authority, ...path] = segments; @@ -51,10 +35,7 @@ export function isRoundTripUri(uri: string): boolean { const MANIFEST_SUFFIX = '/SKILL.md'; -/** - * The skill's root directory URI (no trailing slash), or `undefined` if `skillMdUri` is not a `…/SKILL.md` URI - * directly inside a named directory. - */ +/** The skill's root directory URI (no trailing slash), or `undefined` if `skillMdUri` is not a `…/SKILL.md` URI in a named directory. */ export function skillRootUri(skillMdUri: string): string | undefined { const canonical = canonicalUri(skillMdUri); if (canonical === undefined || !canonical.endsWith(MANIFEST_SUFFIX)) return undefined; @@ -78,11 +59,7 @@ export function lastSegmentOf(rootUri: string): string { return safeDecode(path === '' || path === '/' ? url.host : path.slice(path.lastIndexOf('/') + 1)); } -/** - * True when `uri`, once normalised, is strictly inside `rootUri` and every segment below it decodes to a plain - * name: not empty, `.` or `..`, and free of `/` and NUL. An encoded `%2F` would otherwise carry traversal inside - * one segment. A backslash is allowed: it is legal in POSIX names and {@linkcode joinUri} encodes it as `%5C`. - */ +/** True when `uri` is strictly inside `rootUri` and no segment below it decodes to empty, `.`, `..`, or a name holding `/` or NUL. */ export function isWithin(rootUri: string, uri: string): boolean { const canonical = canonicalUri(uri); if (canonical === undefined || !canonical.startsWith(`${rootUri}/`)) return false; @@ -103,7 +80,6 @@ function decodeOrUndefined(escapes: string): string | undefined { } } -/** The length of the UTF-8 sequence a lead byte announces: 1 for ASCII, and for bytes that cannot lead one. */ function utf8Width(lead: number): number { if (lead >= 0xf0) return 4; if (lead >= 0xe0) return 3; @@ -111,11 +87,7 @@ function utf8Width(lead: number): number { return 1; } -/** - * A run of `%XX` escapes keyed: each valid UTF-8 sequence decoded and re-encoded. A `%XX` that starts none is a - * literal `%` before two characters, keyed `%25XX` with their case kept, exactly as {@linkcode encodeSegment} - * publishes such a name (`10%Dec.md` is `10%25Dec.md`). - */ +// Each valid UTF-8 sequence is decoded and re-encoded; a `%XX` starting none is a literal `%`, as published (`10%25Dec.md`). function keyOfEscapes(run: string): string { let key = ''; for (let at = 0; at < run.length; ) { @@ -132,10 +104,9 @@ function keyOfEscapes(run: string): string { return key; } -/** Runs of `%XX` escapes; splitting on this puts them at odd indexes. */ +// Runs of `%XX` escapes; splitting on this puts them at odd indexes. const ESCAPE_RUN = /((?:%[\dA-Fa-f]{2})+)/; -/** One segment keyed: escape runs normalised, every other character (a stray `%` too) encoded as {@linkcode encodeSegment} does. */ function keyOfSegment(segment: string): string { return segment .split(ESCAPE_RUN) @@ -143,14 +114,7 @@ function keyOfSegment(segment: string): string { .join(''); } -/** - * The one spelling every way of writing a file's URI below `rootUri` shares: canonical, without its fragment, and - * each segment below the root decoded and re-encoded as {@linkcode encodeSegment} does. Each UTF-8 sequence of - * escapes is decoded on its own, and a `%` that starts none is literal (keyed `%25`), so a stray `%` in a plain name - * (`100% off.md`, `10%Dec.md`) keys as published. `Q&A.md`, `Q%26A.md` and `%51&A.md` key alike, as do `%c3%a9` - * and `%C3%A9`, and `^` and `%5E`; `%ff` and `%FF` do not, being literal text. A URI outside the root, or with a - * query, keys as its canonical form without the fragment; `undefined` if it does not parse. - */ +/** The one spelling all ways of writing a file's URI below `rootUri` share (`Q&A.md`, `Q%26A.md#x`); `undefined` if it does not parse. */ export function skillFileKey(rootUri: string, uri: string): string | undefined { let url: URL; try { diff --git a/packages/core-internal/test/ext/skills/bytes.test.ts b/packages/core-internal/test/ext/skills/bytes.test.ts index 93c4dbbc8d..157b3a3d29 100644 --- a/packages/core-internal/test/ext/skills/bytes.test.ts +++ b/packages/core-internal/test/ext/skills/bytes.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { decodeBase64, decodeUtf8Lossless, encodeBase64, encodeUtf8, sha256Digest } from '../../../src/ext/skills/bytes'; @@ -21,4 +21,23 @@ describe('bytes', () => { const bytes = encodeUtf8('hello skills'); expect(await sha256Digest(bytes)).toBe(`sha256:${createHash('sha256').update(bytes).digest('hex')}`); }); + + it('hashes only the bytes of a view, and a view over a SharedArrayBuffer', async () => { + const expected = `sha256:${createHash('sha256').update('hello skills').digest('hex')}`; + expect(await sha256Digest(encodeUtf8('xxhello skillsyy').subarray(2, -2))).toBe(expected); + const shared = new Uint8Array(new SharedArrayBuffer(12)); + shared.set(encodeUtf8('hello skills')); + expect(await sha256Digest(shared)).toBe(expected); + }); + + it('hashes an ArrayBuffer-backed input without copying it', async () => { + const digest = vi.spyOn(globalThis.crypto.subtle, 'digest'); + try { + const bytes = encodeUtf8('hello skills'); + await sha256Digest(bytes); + expect((digest.mock.calls[0]![1] as ArrayBufferView).buffer).toBe(bytes.buffer); + } finally { + digest.mockRestore(); + } + }); }); diff --git a/packages/core-internal/test/ext/skills/entry.test.ts b/packages/core-internal/test/ext/skills/entry.test.ts index 71b3fa46e6..601904b909 100644 --- a/packages/core-internal/test/ext/skills/entry.test.ts +++ b/packages/core-internal/test/ext/skills/entry.test.ts @@ -78,11 +78,42 @@ describe('validateSkillEntry', () => { ] }), /more than once/ + ], + [ + 'a file listed again under another percent-encoding', + entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/Q&A.md', digest, size: 1 }, + { uri: 'skill://acme/refunds/Q%26A.md', digest, size: 1 } + ] + }), + /"skill:\/\/acme\/refunds\/Q%26A\.md" is listed more than once/ ] ])('reports %s', (_label, skill, problem) => { expect(validateSkillEntry(skill).join('\n')).toMatch(problem); }); + it.each(['policy.md#part', 'policy.md#', 'policy.md?v=1', 'policy.md?'])('reports a file URI with a query or fragment (%s)', suffix => { + const skill = entry({ + resources: [ + { uri: 'skill://acme/refunds/SKILL.md', digest, size: 1 }, + { uri: `skill://acme/refunds/references/${suffix}`, digest, size: 1 } + ] + }); + expect(validateSkillEntry(skill)).toEqual([ + `resource "skill://acme/refunds/references/${suffix}" must not have a query or fragment` + ]); + }); + + it('counts the length limits in characters, not UTF-16 code units', () => { + const problems = (fields: Record) => + validateSkillEntry(entry({ frontmatter: { name: 'refunds', description: 'd', ...fields } })).join('\n'); + expect(problems({ description: '😀'.repeat(1024), compatibility: '😀'.repeat(500) })).toBe(''); + expect(problems({ description: '😀'.repeat(1025) })).toMatch(/description/); + expect(problems({ compatibility: '😀'.repeat(501) })).toMatch(/compatibility/); + }); + it('skillEntryProblems also catches schema violations', () => { expect(skillEntryProblems({ uri: 'skill://a/SKILL.md' }).length).toBeGreaterThan(0); expect(skillEntryProblems(entry())).toEqual([]); diff --git a/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts index 1b913e852e..5012776464 100644 --- a/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts +++ b/packages/core-internal/test/ext/skills/frontmatter.differential.test.ts @@ -22,7 +22,19 @@ const ACCEPTED: readonly string[] = [ 'name: x\ndescription: d\nnested:\n level1:\n level2:\n - deep\n', 'name: x\ndescription: d\ntags:\n-\ta\n-\tb\n', 'name: x\ndescription: d\nflow: [-a, ?b, :c]\nmap: {-k: v, ?k: w}\n', - 'name: x\ndescription: d\nflow: [http://x.test/a, b:c, a#b]\nmap: {a:b: c, :k: v, http://x.test: 1, u: http://x.test, k#: v#}\n' + 'name: x\ndescription: d\nflow: [http://x.test/a, b:c, a#b]\nmap: {a:b: c, :k: v, http://x.test: 1, u: http://x.test, k#: v#}\n', + // A tab after a block scalar's content indentation is content. + 'name: x\ndescription: |\n foo\n \tbar\n', + 'name: x\ndescription: |\n \tfoo\n bar\n', + 'name: x\ndescription: |\n \tfoo\n', + 'name: x\ndescription: |\n \t\n foo\n', + 'name: x\ndescription: |-\n foo\n \t\n', + 'name: x\ndescription: |\n foo\n \t# not a comment\n', + 'name: x\ndescription: >\n a\n b\n \tc\n d\n e\n', + 'name: x\ndescription: >\n a\n b\n \tc\n d\n', + 'name: x\ndescription: >\n foo\n\n \tbar\n\n baz\n', + 'name: x\nmetadata:\n note: |\n foo\n \tbar\n next: "1"\n', + 'name: x\ntags:\n- |\n foo\n \tbar\n- x\n' ]; /** Valid YAML the parser deliberately rejects (it must fail closed, never misread). */ @@ -48,7 +60,12 @@ const REJECTED: readonly string[] = [ 'map: {{x: 1}: y}\n', // yaml drops a byte order mark before the first content; the parser does not guess. '\uFEFFname: x\n', - '# comment\n\uFEFFname: x\n' + '# comment\n\uFEFFname: x\n', + // yaml reads `__proto__` as an own key; the parser rejects it wherever a key can be written. + '"__proto__": x\n', + "m:\n '__proto__': x\n", + 'm: {__proto__: x}\n', + 'm: {"__proto__": x}\n' ]; /** Invalid YAML: the yaml library rejects it, so the parser must too. */ @@ -77,7 +94,16 @@ const INVALID: readonly string[] = [ 'a: {k: v:, j: w}\n', 'a: {x #y: z}\n', 'a: {x]: y}\n', - 'a: {x[: y}\n' + 'a: {x[: y}\n', + // A tab inside a block scalar's indentation, blank and comment lines included. + 'a: |\n\tfoo\n', + 'a: |\n\t\n foo\n', + 'a: |\n foo\n \tbar\n', + 'a: |\n foo\n\t\n bar\n', + 'a: |\n foo\n \t\n', + 'a: |\n foo\n\t\nb: 1\n', + 'a: |\n foo\n\t# c\nb: 1\n', + 'k:\n a: |\n foo\n \tbar\n' ]; /** From 1e809478cf40105108c1deb54886967c3a043fe7 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 15:57:04 +0300 Subject: [PATCH 33/45] fix(server): refuse handler overwrites in attach and hash each skill file once - attach() refuses a server that already handles skills/list, skills/get or, for a source that reads directories, resources/directory/read, with McpServer's own check and message, before changing anything. It used to replace the user's handler silently. - The "all or nothing" comment now says only what is true: registerResource stores the template before checking the resources/* handlers, so a low-level resources/read handler still leaves an inert template behind. - baseUri is canonicalised like request URIs; a mixed-case host or explicit default port made every read not-found and dropped every listed resource. A baseUri that does not parse now throws a TypeError. - Under strict, the over-limit problem says to raise limits instead of telling the user to set strict: true. - The catalog build hashes each file when it reads it and keeps only SKILL.md until it is parsed. A per-build cache and deepest-first order read and hash each file once, not once per enclosing skill; warnings now come out deepest skill first. A single large file is still read in full: checking its size first needs a size in the SkillFiles listing. - The tracked-server set, which reload() walks to send list_changed, prunes again only once it doubles past the survivors, not on every attach. - Comments cut to one line and JSDoc dropped from non-public code. --- .../server/src/ext/skills/catalogBuild.ts | 90 +++++++++++-------- .../server/src/ext/skills/catalogSource.ts | 6 +- packages/server/src/ext/skills/errors.ts | 2 +- packages/server/src/ext/skills/extension.ts | 38 ++++---- packages/server/src/ext/skills/files.ts | 8 +- packages/server/src/ext/skills/fsPath.ts | 8 +- packages/server/src/ext/skills/internal.ts | 2 +- packages/server/src/ext/skills/paging.ts | 7 +- .../server/test/ext/skills/catalog.test.ts | 17 ++++ .../test/ext/skills/catalogBuild.test.ts | 31 +++++-- .../server/test/ext/skills/extension.test.ts | 48 +++++++++- 11 files changed, 173 insertions(+), 84 deletions(-) diff --git a/packages/server/src/ext/skills/catalogBuild.ts b/packages/server/src/ext/skills/catalogBuild.ts index dd08ba39d8..1eb7873974 100644 --- a/packages/server/src/ext/skills/catalogBuild.ts +++ b/packages/server/src/ext/skills/catalogBuild.ts @@ -1,12 +1,20 @@ -import type { Skill, SkillResourceEntry } from '@modelcontextprotocol/core/ext/skills'; +import type { Skill } from '@modelcontextprotocol/core/ext/skills'; import { DIRECTORY_MIME_TYPE } from '@modelcontextprotocol/core/ext/skills'; import type { Resource } from '@modelcontextprotocol/core-internal'; -import { buildSkillEntry, compareUris, isRoundTripUri, joinUri, mimeTypeFor } from '@modelcontextprotocol/core-internal/ext/skills'; +import type { SkillFileInput } from '@modelcontextprotocol/core-internal/ext/skills'; +import { + buildSkillEntry, + compareUris, + isRoundTripUri, + joinUri, + mimeTypeFor, + sha256Digest, + validateSkillEntry +} from '@modelcontextprotocol/core-internal/ext/skills'; import { SkillCatalogError } from './errors'; import type { SkillFileEntry, SkillFiles, SkillWarning } from './types'; -/** One published file: where it is in storage and what the entry says about it. */ export interface CatalogFile { readonly path: string; readonly uri: string; @@ -15,7 +23,9 @@ export interface CatalogFile { readonly mimeType: string; } -/** Everything a catalog serves, built once per load or reload. Metadata only, never file contents. */ +type Hashed = Pick; + +// Everything a catalog serves, built once per load or reload: metadata only, never file contents. export interface CatalogSnapshot { readonly ordered: readonly Skill[]; readonly skills: ReadonlyMap; @@ -41,7 +51,7 @@ interface BuiltSkill { readonly skill: Skill; readonly files: readonly CatalogFile[]; readonly manifestSize: number; - /** Every entry below the skill's folder, nested skills' included, in tree order. */ + // Every entry below the skill's folder, nested skills' included, in tree order. readonly inside: readonly TreeEntry[]; } @@ -58,17 +68,16 @@ const errorText = (error: unknown): string => (error instanceof Error ? error.me const NOT_FOLLOWED = 'symlinks and other special files are not followed; copy the file instead'; -/** Any non-directory named exactly `SKILL.md` marks a skill, so a symlinked one is reported rather than ignored. */ +// Any non-directory named exactly `SKILL.md` marks a skill, so a symlinked one is reported rather than ignored. const isManifest = (entry: TreeEntry): boolean => entry.kind !== 'directory' && basename(entry.path) === 'SKILL.md'; -/** Appends `value` to the list kept under `key`. */ function append(lists: Map, key: K, value: V): void { const list = lists.get(key); if (list === undefined) lists.set(key, [value]); else list.push(value); } -/** The URI of `path`, or `undefined` when a name in it has no URI form (a lone surrogate, or no round trip). */ +// `undefined` when a name in `path` has no URI form (a lone surrogate, or no round trip). function representableUri(uriOf: UriOf, path: string): string | undefined { let uri: string; try { @@ -80,7 +89,7 @@ function representableUri(uriOf: UriOf, path: string): string | undefined { return isRoundTripUri(uri) ? uri : undefined; } -/** Every non-hidden entry below the root, depth first. Storage errors fail the build. */ +// Every non-hidden entry below the root, depth first; storage errors fail the build. async function walk(files: SkillFiles): Promise { const tree: TreeEntry[] = []; const visit = async (dir: string): Promise => { @@ -109,7 +118,6 @@ async function readFile(files: SkillFiles, path: string): Promise { } } -/** Walks the storage, builds and checks every skill, and assembles what the catalog serves. */ export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBuildOptions): Promise { const tree = await walk(files); const problems: SkillWarning[] = []; @@ -126,9 +134,7 @@ export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBu } } - // One pass over each entry's folders collects every skill's entries (nested skills' included), in tree order. - // A special file inside a skill is reported when that skill is built; one outside every skill (a symlinked - // skill folder, say) is reported here. A root SKILL.md is reported in the loop below. + // Collect each skill's entries, nested skills' included; a special file outside every skill is reported here. const skillDirs = new Set(tree.filter(entry => isManifest(entry)).map(entry => dirname(entry.path))); const insideOf = new Map(); for (const entry of tree) { @@ -144,8 +150,10 @@ export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBu } const built: BuiltSkill[] = []; - for (const entry of tree) { - if (!isManifest(entry)) continue; + const hashed = new Map(); + // Deepest first, so each file is read and hashed once: by the innermost skill that holds it. + const manifests = tree.filter(entry => isManifest(entry)).toSorted((a, b) => b.path.split('/').length - a.path.split('/').length); + for (const entry of manifests) { const dir = dirname(entry.path); if (dir === '') { report({ @@ -154,7 +162,7 @@ export async function buildCatalogSnapshot(files: SkillFiles, options: CatalogBu }); continue; } - const skill = await buildOne(files, insideOf.get(dir) ?? [], dir, uriOf, options, report); + const skill = await buildOne(files, insideOf.get(dir) ?? [], dir, uriOf, options, report, hashed); if (skill !== undefined) built.push(skill); } if (problems.length > 0) throw new SkillCatalogError(problems); @@ -168,14 +176,17 @@ async function buildOne( dir: string, uriOf: UriOf, options: CatalogBuildOptions, - report: (warning: SkillWarning) => void + report: (warning: SkillWarning) => void, + hashed: Map ): Promise { const unsupported = inside.find(entry => entry.kind === 'unsupported'); if (unsupported !== undefined) { report({ path: unsupported.path, message: `${NOT_FOLLOWED} (skill skipped)` }); return undefined; } - const inputs: Array<{ path: string; uri: string; bytes: Uint8Array }> = []; + const manifestPath = `${dir}/SKILL.md`; + const manifest: SkillFileInput[] = []; + const catalogFiles: CatalogFile[] = []; for (const entry of inside) { // Folders too: an empty one's name appears only in its parent's directory listing. const uri = representableUri(uriOf, entry.path); @@ -183,37 +194,41 @@ async function buildOne( report({ path: entry.path, message: 'this name cannot be represented in a URI (skill skipped)' }); return undefined; } - if (entry.kind === 'file') inputs.push({ path: entry.path, uri, bytes: await readFile(files, entry.path) }); + if (entry.kind !== 'file') continue; + const own = entry.path === manifestPath; + let file = own ? undefined : hashed.get(entry.path); + if (file === undefined) { + const bytes = await readFile(files, entry.path); + if (own) manifest.push({ uri, bytes }); + file = { digest: await sha256Digest(bytes), size: bytes.byteLength }; + hashed.set(entry.path, file); + } + catalogFiles.push({ path: entry.path, uri, ...file, mimeType: mimeTypeFor(basename(entry.path)) }); } - const manifestUri = uriOf(`${dir}/SKILL.md`); - const { skill, problems } = await buildSkillEntry(manifestUri, inputs); + // Parse SKILL.md on its own, then validate the entry that lists every file's digest. + const parsed = await buildSkillEntry(uriOf(manifestPath), manifest); + const resources = catalogFiles.map(({ uri, digest, size }) => ({ uri, digest, size })).toSorted((a, b) => compareUris(a.uri, b.uri)); + const skill = parsed.skill === undefined ? undefined : { ...parsed.skill, resources }; + const problems = skill === undefined ? parsed.problems : validateSkillEntry(skill); if (skill === undefined || problems.length > 0) { - for (const message of problems) report({ path: `${dir}/SKILL.md`, message: `${message} (skill skipped)` }); + for (const message of problems) report({ path: manifestPath, message: `${message} (skill skipped)` }); return undefined; } let totalBytes = 0; - for (const input of inputs) totalBytes += input.bytes.byteLength; - if (inputs.length > options.limits.files || totalBytes > options.limits.bytes) { + for (const file of catalogFiles) totalBytes += file.size; + if (catalogFiles.length > options.limits.files || totalBytes > options.limits.bytes) { + const remedy = options.strict ? 'Raise limits to allow it' : 'Set strict: true to fail instead, or raise limits'; report({ path: dir, message: - `${inputs.length} files / ${totalBytes} bytes exceeds the limit of ${options.limits.files} files / ` + - `${options.limits.bytes} bytes; some hosts may decline it. Set strict: true to fail instead, or raise limits` + `${catalogFiles.length} files / ${totalBytes} bytes exceeds the limit of ${options.limits.files} files / ` + + `${options.limits.bytes} bytes; some hosts may decline it. ${remedy}` }); } - const digests = new Map((skill.resources as SkillResourceEntry[]).map(resource => [resource.uri, resource.digest])); - const catalogFiles = inputs.map(input => ({ - path: input.path, - uri: input.uri, - size: input.bytes.byteLength, - digest: digests.get(input.uri)!, - mimeType: mimeTypeFor(basename(input.path)) - })); - const manifestSize = inputs.find(input => input.uri === manifestUri)?.bytes.byteLength ?? 0; + const manifestSize = catalogFiles.find(file => file.path === manifestPath)?.size ?? 0; return { dir, skill, files: catalogFiles, manifestSize, inside }; } -/** Freezes `value` and every object and array inside it. */ function deepFreeze(value: T): T { if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) { Object.freeze(value); @@ -223,8 +238,7 @@ function deepFreeze(value: T): T { } function assemble(built: readonly BuiltSkill[], tree: readonly TreeEntry[], uriOf: UriOf): CatalogSnapshot { - // Entries are served as they are (trusted, never re-validated) and handed to callers by `entries`: frozen, so an - // edit (say, of a description for display) throws instead of changing what clients receive. + // Frozen: entries are served unchecked and handed out by `entries`, so an edit must throw, not reach clients. const ordered = Object.freeze(built.map(item => deepFreeze(item.skill)).toSorted((a, b) => compareUris(a.uri, b.uri))); const files = new Map(); const dirPaths = new Set(); diff --git a/packages/server/src/ext/skills/catalogSource.ts b/packages/server/src/ext/skills/catalogSource.ts index 2894f24c18..28940bed2d 100644 --- a/packages/server/src/ext/skills/catalogSource.ts +++ b/packages/server/src/ext/skills/catalogSource.ts @@ -6,14 +6,14 @@ import { trustedSources } from './internal'; import { paginate } from './paging'; import type { SkillFiles, SkillReadResult, SkillSource, SkillWarning } from './types'; -/** The catalog's current snapshot; `reload()` swaps `current` in one assignment. */ +// `reload()` swaps `current` in one assignment. export interface CatalogState { current: CatalogSnapshot; } const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)); -/** Serves a catalog snapshot as a trusted {@linkcode SkillSource}: its entries were built and checked by the SDK. */ +// A trusted source: the SDK built and checked the snapshot's entries. export function catalogSource( state: CatalogState, files: SkillFiles, @@ -45,7 +45,7 @@ export function catalogSource( return source; } -/** Reads a published file through the adapter and serves it only if it still matches its entry. */ +// Reads a published file through the adapter and serves it only if it still matches its entry. async function readChecked( snapshot: CatalogSnapshot, files: SkillFiles, diff --git a/packages/server/src/ext/skills/errors.ts b/packages/server/src/ext/skills/errors.ts index e25c41462e..63eab39e31 100644 --- a/packages/server/src/ext/skills/errors.ts +++ b/packages/server/src/ext/skills/errors.ts @@ -41,7 +41,7 @@ export class SkillCatalogError extends Error { } } -/** Default warning sink: stderr, so it never corrupts a stdio transport. */ +// Stderr, so a warning never corrupts a stdio transport. export function defaultWarn(warning: SkillWarning): void { // eslint-disable-next-line no-console -- the documented default sink for skills warnings console.warn(`[mcp-sdk] skills: ${warning.path}: ${warning.message}`); diff --git a/packages/server/src/ext/skills/extension.ts b/packages/server/src/ext/skills/extension.ts index 03fcb4adad..2ce23867b2 100644 --- a/packages/server/src/ext/skills/extension.ts +++ b/packages/server/src/ext/skills/extension.ts @@ -52,14 +52,10 @@ function toContents(uri: string, result: SkillReadResult): ReadResourceResult['c return 'text' in result ? { uri, ...mimeType, text: result.text } : { uri, ...mimeType, blob: encodeBase64(result.bytes) }; } -/** Servers any skills extension is attached to: one per server (SEP-3371 rejects a duplicate extension identifier). */ +// Servers with a skills extension attached: one per server, as SEP-3371 rejects a duplicate extension identifier. const skillsServers = new WeakSet(); -/** - * What runs after `attach` registered everything, by extension (`SkillCatalog` tracks the servers it notifies). - * Package-internal and off the class surface: SEP-3371 registration (`extensions: [skills]`) will not go through - * `attach`, so a subclass must not be able to hook it. - */ +// Runs after a successful `attach`, by extension (`SkillCatalog` tracks servers); off the class so no subclass hooks it. const afterAttach = new WeakMap void>(); const ONE_PER_SERVER = @@ -94,8 +90,10 @@ export class SkillsExtension { constructor(options: SkillSourceOptions) { if (options.cacheHint !== undefined) assertValidCacheHint(options.cacheHint, 'skills cacheHint'); const baseUri = options.baseUri ?? 'skill://'; - if (!SCHEME_URI.test(baseUri)) throw new TypeError(`baseUri must look like "scheme://…" (got ${JSON.stringify(baseUri)})`); - this.#baseRoot = baseUri.endsWith('://') ? baseUri : `${baseUri.replace(/\/+$/, '')}/`; + // Canonical, like the request URIs that the template and the listResources filter are matched against. + const canonical = SCHEME_URI.test(baseUri) ? canonicalUri(baseUri) : undefined; + if (canonical === undefined) throw new TypeError(`baseUri must be a "scheme://…" URI (got ${JSON.stringify(baseUri)})`); + this.#baseRoot = canonical.endsWith('://') ? canonical : `${canonical.replace(/\/+$/, '')}/`; this.#source = options.source; this.#trusted = trustedSources.has(options.source); this.#cacheHint = options.cacheHint; @@ -109,7 +107,8 @@ export class SkillsExtension { * the skills methods, then declares the capability. Must run before * connecting. Calling it again for the same server does nothing. One * skills extension per server: attaching another one throws and leaves the - * server as it was. + * server as it was, as does attaching to a server that already handles one + * of the skills methods. */ attach(mcp: McpServer): void { if (this.#attached.has(mcp)) return; @@ -118,8 +117,10 @@ export class SkillsExtension { const server = mcp.server; const directoryRead = this.#source.readDirectory !== undefined; const list = this.#source.listResources === undefined ? undefined : (ctx: ServerContext) => this.#listResources(ctx); - // All or nothing: `registerResource` rejects a taken template name before it changes anything, and nothing - // after it can throw. The capability is declared last, so a server never advertises skills it does not serve. + server.assertCanSetRequestHandler(SKILLS_LIST_METHOD); + server.assertCanSetRequestHandler(SKILLS_GET_METHOD); + if (directoryRead) server.assertCanSetRequestHandler(RESOURCES_DIRECTORY_READ_METHOD); + // Check before changing anything; declare the capability last, so a server never advertises skills it does not serve. mcp.registerResource( SKILLS_EXTENSION_ID, new ResourceTemplate(`${this.#baseRoot}{+path}`, { list }), @@ -227,7 +228,7 @@ interface CatalogInit { readonly sourceOptions: SkillSourceOptions; } -/** At this many tracked servers, `attach` first drops references to servers that were garbage-collected. */ +// Fewest tracked servers at which `attach` drops references to collected ones; then again at twice the survivors. const PRUNE_THRESHOLD = 1024; function resolveLimits(limits: SkillCatalogOptions['limits']): { files: number; bytes: number } { @@ -240,7 +241,7 @@ function resolveLimits(limits: SkillCatalogOptions['limits']): { files: number; return resolved; } -/** What `resources/list_changed` is about: which skills exist, and each one's name and description. */ +// What `resources/list_changed` is about: which skills exist, and each one's name and description. function listingKey(snapshot: CatalogSnapshot): string { return JSON.stringify(snapshot.listed.map(resource => [resource.uri, resource.name, resource.description])); } @@ -263,6 +264,7 @@ export class SkillCatalog extends SkillsExtension { readonly #files: SkillFiles; readonly #build: CatalogBuildOptions; readonly #servers = new Set>(); + #pruneAt = PRUNE_THRESHOLD; #queue: Promise = Promise.resolve(); private constructor(init: CatalogInit) { @@ -324,12 +326,12 @@ export class SkillCatalog extends SkillsExtension { return run; } - /** Remembers `mcp`, weakly, for `resources/list_changed` after a reload. */ #track(mcp: McpServer): void { // Without WeakRef, holding servers strongly would keep every per-request server alive; track none instead. if (typeof WeakRef !== 'function') return; - if (this.#servers.size >= PRUNE_THRESHOLD) { + if (this.#servers.size >= this.#pruneAt) { for (const reference of this.#servers) if (reference.deref() === undefined) this.#servers.delete(reference); + this.#pruneAt = Math.max(PRUNE_THRESHOLD, 2 * this.#servers.size); } this.#servers.add(new WeakRef(mcp)); } @@ -343,11 +345,7 @@ export class SkillCatalog extends SkillsExtension { return { ...diffSnapshots(previous, next), listChanged }; } - /** - * Sends `resources/list_changed` to every connected server. A send that - * fails (a transport that rejects, say) becomes a warning: it must never - * surface as an unhandled rejection, nor stop the other servers. - */ + // A send that fails becomes a warning: never an unhandled rejection, and never a reason to skip the other servers. #notify(): void { for (const reference of this.#servers) { const mcp = reference.deref(); diff --git a/packages/server/src/ext/skills/files.ts b/packages/server/src/ext/skills/files.ts index d24c341b21..8b93955ddf 100644 --- a/packages/server/src/ext/skills/files.ts +++ b/packages/server/src/ext/skills/files.ts @@ -2,7 +2,7 @@ import { encodeUtf8 } from '@modelcontextprotocol/core-internal/ext/skills'; import type { SkillFileEntry, SkillFiles } from './types'; -/** Splits a relative `/`-separated path, rejecting `..` and empty paths. */ +// Splits a relative `/`-separated path, rejecting `..` and empty paths. export function splitRelativePath(path: string, label: string): string[] { const segments = path.split('/').filter(segment => segment !== '' && segment !== '.'); if (segments.length === 0 || segments.includes('..')) { @@ -11,11 +11,11 @@ export function splitRelativePath(path: string, label: string): string[] { return segments; } -/** A memory record's directories and files, by normalised path. */ +// A memory record's directories and files, by normalised path. interface MemoryIndex { - /** Children of every directory (`''` is the root), in record order. */ + // Children of every directory (`''` is the root), in record order. readonly dirs: ReadonlyMap>; - /** The record key that holds each file. */ + // The record key that holds each file. readonly files: ReadonlyMap; } diff --git a/packages/server/src/ext/skills/fsPath.ts b/packages/server/src/ext/skills/fsPath.ts index 6e294058ec..82920ca943 100644 --- a/packages/server/src/ext/skills/fsPath.ts +++ b/packages/server/src/ext/skills/fsPath.ts @@ -1,6 +1,6 @@ import { splitRelativePath } from './files'; -/** The parts of `node:path` the containment rule needs: `path`, `path.win32` and `path.posix` all fit. */ +// The parts of `node:path` the containment rule needs: `path`, `path.win32` and `path.posix` all fit. export interface PathRules { readonly sep: string; join(...paths: string[]): string; @@ -11,11 +11,7 @@ export interface PathRules { const escapes = (relative: string): TypeError => new TypeError(`path must be a relative path that stays inside the skills folder (got ${JSON.stringify(relative)})`); -/** - * Where `relative` (an adapter path: `/`-separated, `''` for the root) is under `base`, by the platform's `rules`. - * Throws a `TypeError` for any path that could leave `base`: a `..` segment, also one written with the platform's - * own separator (`..\x` on Windows), or a result outside `base`. - */ +// Where adapter path `relative` is under `base`; a TypeError for any path that could leave it, by the platform's `rules`. export function fsPathWithin(rules: PathRules, base: string, relative: string): string { if (relative === '') return base; const segments = splitRelativePath(relative, 'path'); diff --git a/packages/server/src/ext/skills/internal.ts b/packages/server/src/ext/skills/internal.ts index c4706e39ef..7483ccb09c 100644 --- a/packages/server/src/ext/skills/internal.ts +++ b/packages/server/src/ext/skills/internal.ts @@ -1,4 +1,4 @@ import type { SkillSource } from './types'; -/** Sources whose entries the SDK built itself and so does not re-validate per request. Package-internal. */ +// Sources whose entries the SDK built itself, so they are not re-validated per request. export const trustedSources = new WeakSet(); diff --git a/packages/server/src/ext/skills/paging.ts b/packages/server/src/ext/skills/paging.ts index ae722ad098..2b4dc7bd68 100644 --- a/packages/server/src/ext/skills/paging.ts +++ b/packages/server/src/ext/skills/paging.ts @@ -3,7 +3,7 @@ import { compareUris, decodeBase64, decodeUtf8Lossless, encodeBase64, encodeUtf8 const CURSOR_PREFIX = 'after:'; -/** An opaque, URL-safe cursor meaning "after this URI". */ +// An opaque, URL-safe cursor meaning "after this URI". export function encodeCursor(uri: string): string { return encodeBase64(encodeUtf8(CURSOR_PREFIX + uri)) .replaceAll('+', '-') @@ -11,7 +11,7 @@ export function encodeCursor(uri: string): string { .replace(/=+$/, ''); } -/** The URI a cursor points after, or `undefined` when it is not one of ours. */ +// The URI a cursor points after, or `undefined` when it is not one of ours. export function decodeCursor(cursor: string): string | undefined { try { const base64 = cursor.replaceAll('-', '+').replaceAll('_', '/'); @@ -22,7 +22,7 @@ export function decodeCursor(cursor: string): string | undefined { } } -/** One page of URI-sorted `items` after `cursor`. Entries added or removed elsewhere never shift the boundary. */ +// One page of URI-sorted `items` after `cursor`; entries added or removed elsewhere never shift the boundary. export function paginate( items: readonly T[], cursor: string | undefined, @@ -42,7 +42,6 @@ export function paginate( return end < items.length && last !== undefined ? { items: page, nextCursor: encodeCursor(key(last)) } : { items: page }; } -/** The `pageSize` default (100) and its validation: a positive safe integer or `Infinity`. */ export function resolvePageSize(pageSize: number | undefined): number { if (pageSize === undefined) return 100; if (pageSize === Number.POSITIVE_INFINITY || (Number.isSafeInteger(pageSize) && pageSize > 0)) return pageSize; diff --git a/packages/server/test/ext/skills/catalog.test.ts b/packages/server/test/ext/skills/catalog.test.ts index 1d50766b9f..505e3cebe2 100644 --- a/packages/server/test/ext/skills/catalog.test.ts +++ b/packages/server/test/ext/skills/catalog.test.ts @@ -163,6 +163,23 @@ describe('SkillCatalog serving', () => { for (const server of servers) expect(await resultOf(server, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); }); + it('keeps attach cheap while many attached servers are still alive', async () => { + const { catalog } = await loaded(); + const servers = Array.from({ length: 1100 }, () => newServer()); + const deref = WeakRef.prototype.deref; + let derefs = 0; + WeakRef.prototype.deref = function (this: WeakRef) { + derefs++; + return deref.call(this); + }; + try { + for (const server of servers) catalog.attach(server); + } finally { + WeakRef.prototype.deref = deref; + } + expect(derefs).toBeLessThan(2 * servers.length); + }); + it('hands out frozen entries: an edit throws and never changes what is served', async () => { const { catalog, mcp } = await loaded(); const refunds = catalog.entries.find(skill => skill.uri === REFUNDS)!; diff --git a/packages/server/test/ext/skills/catalogBuild.test.ts b/packages/server/test/ext/skills/catalogBuild.test.ts index b61a32c4c9..302bd7b30c 100644 --- a/packages/server/test/ext/skills/catalogBuild.test.ts +++ b/packages/server/test/ext/skills/catalogBuild.test.ts @@ -22,9 +22,9 @@ function optionsWith(overrides: Partial = {}) { } /** The problems a strict build fails with, sorted by path; `[]` when it succeeds. Any other error fails the test. */ -async function strictProblems(files: SkillFiles): Promise { +async function strictProblems(files: SkillFiles, overrides: Partial = {}): Promise { try { - await buildCatalogSnapshot(files, optionsWith({ strict: true }).options); + await buildCatalogSnapshot(files, optionsWith({ ...overrides, strict: true }).options); return []; } catch (error) { if (error instanceof SkillCatalogError) return error.problems.toSorted(byPath); @@ -235,10 +235,29 @@ describe('buildCatalogSnapshot', () => { const lenient = optionsWith({ limits }); const snapshot = await buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), lenient.options); expect(snapshot.skills.has(REFUNDS)).toBe(true); - expect(lenient.warnings).toContainEqual({ path: 'acme/billing/refunds', message: expect.stringMatching(/exceeds the limit/) }); - await expect( - buildCatalogSnapshot(memorySkillFiles(fixtureFiles()), optionsWith({ limits, strict: true }).options) - ).rejects.toBeInstanceOf(SkillCatalogError); + expect(lenient.warnings).toContainEqual({ + path: 'acme/billing/refunds', + message: expect.stringMatching(/exceeds the limit.*Set strict: true to fail instead/) + }); + expect(await strictProblems(memorySkillFiles(fixtureFiles()), { limits })).toContainEqual({ + path: 'acme/billing/refunds', + message: expect.not.stringMatching(/strict/) + }); + }); + + it('reads each file once per build, however deeply skills nest', async () => { + const memory = memorySkillFiles(fixtureFiles()); + const reads = new Map(); + const files: SkillFiles = { + list: dir => memory.list(dir), + read: path => { + reads.set(path, (reads.get(path) ?? 0) + 1); + return memory.read(path); + } + }; + const snapshot = await buildCatalogSnapshot(files, optionsWith().options); + expect([...reads.keys()].toSorted()).toEqual([...snapshot.files.values()].map(file => file.path).toSorted()); + expect(new Set(reads.values())).toEqual(new Set([1])); }); it('always fails on storage errors', async () => { diff --git a/packages/server/test/ext/skills/extension.test.ts b/packages/server/test/ext/skills/extension.test.ts index 57fed5fcb0..90aa267ff9 100644 --- a/packages/server/test/ext/skills/extension.test.ts +++ b/packages/server/test/ext/skills/extension.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import { ListSkillsRequestParamsSchema, ReadResourceDirectoryRequestParamsSchema } from '@modelcontextprotocol/core/ext/skills'; import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; import { describe, expect, it } from 'vitest'; @@ -179,8 +180,29 @@ describe('baseUri', () => { expect(contents).toEqual([{ uri: github.uri, text: 'x' }]); }); - it('rejects a baseUri without a scheme', () => { + it('matches requests however the baseUri spells its host and port', async () => { + const uri = 'https://example.com/skills/refunds/SKILL.md'; + const listed = { uri, name: 'refunds' }; + const { mcp, warnings } = attached({ + baseUri: 'https://Example.com:443/skills/', + source: sourceOf({ + read: async requested => (requested === uri ? { text: 'x' } : undefined), + listResources: async () => [listed] + }) + }); + expect(await resultOf(mcp, 'resources/templates/list')).toMatchObject({ + resourceTemplates: [{ uriTemplate: 'https://example.com/skills/{+path}' }] + }); + expect(await resultOf(mcp, 'resources/read', { uri: 'https://EXAMPLE.com:443/skills/refunds/SKILL.md' })).toMatchObject({ + contents: [{ uri, text: 'x' }] + }); + expect(await resultOf(mcp, 'resources/list')).toMatchObject({ resources: [listed] }); + expect(warnings).toEqual([]); + }); + + it('rejects a baseUri without a scheme, or one that does not parse', () => { expect(() => new SkillsExtension({ source: sourceOf(), baseUri: 'skills' })).toThrow(TypeError); + expect(() => new SkillsExtension({ source: sourceOf(), baseUri: 'https://exa mple.com' })).toThrow(TypeError); }); }); @@ -218,6 +240,30 @@ describe('attach', () => { expect(own.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': {} }); }); + it('refuses to replace a handler the server already has for a skills method, changing nothing', async () => { + const mcp = newServer(); + mcp.server.setRequestHandler('skills/list', { params: ListSkillsRequestParamsSchema }, () => ({ skills: [] })); + const extension = new SkillsExtension({ source: sourceOf() }); + expect(() => extension.attach(mcp)).toThrow('A request handler for skills/list already exists, which would be overridden'); + expect(mcp.server.getCapabilities().extensions).toBeUndefined(); + expect(await resultOf(mcp, 'skills/list')).toMatchObject({ skills: [] }); + mcp.server.removeRequestHandler('skills/list'); + extension.attach(mcp); + expect(await resultOf(mcp, 'skills/list')).toMatchObject({ skills: [gitWorkflow] }); + }); + + it('refuses to replace a resources/directory/read handler when the source reads directories', () => { + const mcp = newServer(); + mcp.server.setRequestHandler('resources/directory/read', { params: ReadResourceDirectoryRequestParamsSchema }, () => ({ + resources: [] + })); + const withDirectories = new SkillsExtension({ source: sourceOf({ readDirectory: async () => ({ resources: [] }) }) }); + expect(() => withDirectories.attach(mcp)).toThrow(/resources\/directory\/read already exists/); + expect(mcp.server.getCapabilities().extensions).toBeUndefined(); + new SkillsExtension({ source: sourceOf() }).attach(mcp); + expect(mcp.server.getCapabilities().extensions).toEqual({ 'io.modelcontextprotocol/skills': {} }); + }); + it('declares nothing when the template name is already taken', () => { const mcp = newServer(); mcp.registerResource( From 8afe26057d49efd671fcb3859040e87edec6f031 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 15:57:04 +0300 Subject: [PATCH 34/45] fix(client): close the frontmatter __proto__ bypass and share Client's paging - A __proto__ object in an entry's frontmatter became the parsed object's prototype through Zod's catchall, so a host read fields such as allowed-tools that SKILL.md lacks while verification, comparing own keys, reported a match. The shared frontmatter schema refuses it on both sides, and verification compares every field a reader sees. - read() requires a content item for the requested URI, compared in canonical form, instead of falling back to the first item; a dynamic skill could return another resource's bytes as the requested file. - read() refetches with cacheMode 'refresh' only when the failing copy came from the response cache, as SEP-2640 requires, instead of on every verification failure. The changeset and docs say so. - SkillsClient shares Client's page walk and strict-capabilities flag through a package-internal hook set in Client's static block, instead of reading privates by name, copying the page cap and running its own paginator that stopped silently on any repeated cursor. - read() validates host-supplied entries and throws a TypeError before any request; the exact-spelling-first lookup is gone, as a valid entry lists each file once. --- .changeset/skills-extension.md | 2 +- docs/clients/skills.md | 2 +- packages/client/src/client/client.ts | 79 +++++++++++--- .../client/src/ext/skills/clientInternals.ts | 22 ---- .../client/src/ext/skills/skillsClient.ts | 100 ++++++++---------- packages/client/src/ext/skills/verify.ts | 13 ++- .../test/ext/skills/clientInternals.test.ts | 19 ---- packages/client/test/ext/skills/read.test.ts | 97 ++++++++++++----- .../test/ext/skills/skillsClient.test.ts | 37 ++++++- .../client/test/ext/skills/verify.test.ts | 9 ++ packages/core/src/ext/skills/schemas.ts | 11 +- packages/core/test/ext/skills/schemas.test.ts | 15 ++- 12 files changed, 247 insertions(+), 159 deletions(-) delete mode 100644 packages/client/src/ext/skills/clientInternals.ts delete mode 100644 packages/client/test/ext/skills/clientInternals.test.ts diff --git a/.changeset/skills-extension.md b/.changeset/skills-extension.md index 74ec24c590..4afefeae4a 100644 --- a/.changeset/skills-extension.md +++ b/.changeset/skills-extension.md @@ -12,7 +12,7 @@ Add the Skills extension (SEP-2640), reachable through new `ext/skills` subpaths - `@modelcontextprotocol/server/ext/skills`: `SkillsExtension` (`attach(server)` serves `skills/list`, `skills/get`, `resources/directory/read` and a `skill://{+path}` resource template), `SkillsExtension.load(files)` returning a `SkillCatalog` with `entries` and `reload()`, the `SkillFiles` storage adapter with `memorySkillFiles`, `SkillSource` for generated catalogs, `skillEntry`, and `SkillCatalogError`. Invalid skills are skipped with a warning (`onWarning`), or fail under `strict`. A file whose bytes are valid UTF-8 is served as `text`, whatever its MIME type; any other file as a base64 `blob`. - `@modelcontextprotocol/server/ext/skills/node`: `loadSkills(folder)` and `nodeSkillFiles(folder)`. -- `@modelcontextprotocol/client/ext/skills`: `SkillsClient` with `list()`, `get()`, `read(skill, path?)` (size, digest and `SKILL.md` frontmatter verified; a file that fails a check is fetched once more with `cacheMode: 'refresh'`, then `SkillVerificationError` on a mismatch) and `readDirectory()`. +- `@modelcontextprotocol/client/ext/skills`: `SkillsClient` with `list()`, `get()`, `read(skill, path?)` (size, digest and `SKILL.md` frontmatter verified, `SkillVerificationError` on a mismatch; a copy from the response cache that fails a check is fetched once more with `cacheMode: 'refresh'` first) and `readDirectory()`. - `@modelcontextprotocol/core/ext/skills`: schemas, types and constants, including `SKILL_LIMITS`. Works on 2025-11-25 and 2026-07-28; caching fields (`ttlMs`, `cacheScope`) are sent on 2026-07-28 only. diff --git a/docs/clients/skills.md b/docs/clients/skills.md index 5862ce5e09..6545d9a7e0 100644 --- a/docs/clients/skills.md +++ b/docs/clients/skills.md @@ -96,7 +96,7 @@ The entry does not list `references/MISSING.md`, so `read` refuses it before sen unlisted skill://git-workflow/references/MISSING.md ``` -`reason` is `unlisted`, `size`, `digest` or `frontmatter`; a path that does not parse is `unlisted` too. When a size, digest or frontmatter check fails, `read` fetches the file once more with `cacheMode: 'refresh'` and throws only if the fresh copy fails as well. On any reason, do not use the file; for `SKILL.md`, do not load the skill. +`reason` is `unlisted`, `size`, `digest` or `frontmatter`; a path that does not parse is `unlisted` too. When a size, digest or frontmatter check fails on a copy the client served from its response cache, `read` fetches the file once more with `cacheMode: 'refresh'` and throws only if the fresh copy fails as well; a copy that came from the server fails at once. On any reason, do not use the file; for `SKILL.md`, do not load the skill. ## Browse a skill folder diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8118b60cea..096a8660e2 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -435,6 +435,22 @@ const LIST_CHANGED_EVICTIONS: Readonly> = { const DEFAULT_LIST_MAX_PAGES = 64; +// Package-internal (not exported from the package): what the `ext/` clients share with Client. Set in Client's static block. +export const clientInternals = {} as { + enforceStrictCapabilities(client: Client): boolean; + walkPages( + client: Client, + method: string, + fetchPage: (cursor: string | undefined) => Promise, + items: (page: R) => unknown[] + ): Promise; + readResource( + client: Client, + params: ReadResourceRequest['params'], + options?: CacheableRequestOptions + ): Promise<{ result: ReadResourceResult; cached: boolean }>; +}; + /** * A handle to an open `subscriptions/listen` stream (protocol revision * 2026-07-28). Change notifications delivered on the stream dispatch to the @@ -572,6 +588,12 @@ export class Client extends Protocol { /** Backing store for {@linkcode getDiscoverResult}. Per-connection. */ private _discoverResult?: DiscoverResult; + static { + clientInternals.enforceStrictCapabilities = client => client._enforceStrictCapabilities; + clientInternals.walkPages = (client, method, fetchPage, items) => client._walkPages(method, fetchPage, items); + clientInternals.readResource = (client, params, options) => client._readResource(params, options); + } + /** * Clears every per-connection field in one place. Called at the start of * each fresh (non-resuming) connect and from `close()`, so a stale @@ -1725,7 +1747,34 @@ export class Client extends Protocol { // when it observes the bump (the result still returns to the caller — // it just is not cached). const generation = this._cache.captureGeneration(method); - const acc = (await this.request({ method, ...(baseParams && { params: { ...baseParams } }) }, options)) as R; + const fetchPage = (cursor: string | undefined): Promise => { + const params = cursor === undefined ? baseParams && { ...baseParams } : { ...baseParams, cursor }; + return this.request({ method, ...(params && { params }) }, options) as Promise; + }; + const acc = await this._walkPages(method, fetchPage, items); + // delete, not `= undefined`: the cache's JSON codec drops + // explicit-undefined properties, so only absence keeps + // `'nextCursor' in result` identical between wire and cache hit. + delete acc.nextCursor; + finalize?.(acc); + if (bypass) return acc; + // The aggregate is ALWAYS written: even when the resolved TTL is ≤0 + // the entry is stored already-stale (mcp.d's `retainForSchema` + // posture) so the `tools/list`-derived index keeps working regardless, + // while the freshness gate in `_serveFromCache` never serves it. + // Page-1 carries the result-level `ttlMs`/`cacheScope` (`acc` IS the + // mutated page-1 object). + await this._cache.write(method, acc, generation, this._freshness(acc)); + return acc; + } + + // Every page's items appended to page 1's: `listMaxPages` caps the walk, a page repeating the one before ends it. + private async _walkPages( + method: string, + fetchPage: (cursor: string | undefined) => Promise, + items: (result: R) => unknown[] + ): Promise { + const acc = await fetchPage(undefined); let cursor = acc.nextCursor; let previous = acc; let pages = 1; @@ -1737,7 +1786,7 @@ export class Client extends Protocol { { method, listMaxPages: this._listMaxPages } ); } - const page = (await this.request({ method, params: { ...baseParams, cursor } }, options)) as R; + const page = await fetchPage(cursor); // Same items and same cursor as the page before: the server made no progress. if (page.nextCursor === cursor && JSON.stringify(items(page)) === JSON.stringify(items(previous))) break; items(acc).push(...items(page)); @@ -1745,19 +1794,6 @@ export class Client extends Protocol { cursor = page.nextCursor; pages++; } - // delete, not `= undefined`: the cache's JSON codec drops - // explicit-undefined properties, so only absence keeps - // `'nextCursor' in result` identical between wire and cache hit. - delete acc.nextCursor; - finalize?.(acc); - if (bypass) return acc; - // The aggregate is ALWAYS written: even when the resolved TTL is ≤0 - // the entry is stored already-stale (mcp.d's `retainForSchema` - // posture) so the `tools/list`-derived index keeps working regardless, - // while the freshness gate in `_serveFromCache` never serves it. - // Page-1 carries the result-level `ttlMs`/`cacheScope` (`acc` IS the - // mutated page-1 object). - await this._cache.write(method, acc, generation, this._freshness(acc)); return acc; } @@ -1882,8 +1918,17 @@ export class Client extends Protocol { * unbounded). */ async readResource(params: ReadResourceRequest['params'], options?: CacheableRequestOptions): Promise { + const { result } = await this._readResource(params, options); + return result; + } + + // `readResource`, and whether the result came from the response cache. + private async _readResource( + params: ReadResourceRequest['params'], + options: CacheableRequestOptions | undefined + ): Promise<{ result: ReadResourceResult; cached: boolean }> { const hit = await this._serveFromCache('resources/read', params.uri, options); - if (hit !== undefined) return hit; + if (hit !== undefined) return { result: hit, cached: true }; // Capture the per-URI eviction generation BEFORE the request: a // `notifications/resources/updated` for this URI arriving while the // read is in flight bumps it (via `evictKey`), and the terminal @@ -1908,7 +1953,7 @@ export class Client extends Protocol { await this._cache.evictKey('resources/read', params.uri); } } - return result; + return { result, cached: false }; } /** Subscribes to change notifications for a resource. The server must support resource subscriptions. */ diff --git a/packages/client/src/ext/skills/clientInternals.ts b/packages/client/src/ext/skills/clientInternals.ts deleted file mode 100644 index b5e7bc1433..0000000000 --- a/packages/client/src/ext/skills/clientInternals.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Client } from '../../client/client'; - -/** Mirrors `DEFAULT_LIST_MAX_PAGES` in `client/client.ts`. */ -const DEFAULT_LIST_MAX_PAGES = 64; - -interface ClientPrivates { - _enforceStrictCapabilities?: boolean; - _listMaxPages?: number; -} - -/** - * The two Client settings the skills client follows. Client keeps them - * private; this reads them by name, and `clientInternals.test.ts` fails if - * they are renamed. - */ -export function clientSettings(client: Client): { enforceStrictCapabilities: boolean; listMaxPages: number } { - const privates = client as unknown as ClientPrivates; - return { - enforceStrictCapabilities: privates._enforceStrictCapabilities ?? false, - listMaxPages: privates._listMaxPages ?? DEFAULT_LIST_MAX_PAGES - }; -} diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index d10b742831..2371528f44 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -20,12 +20,12 @@ import { skillRootUri, splitFrontmatter } from '@modelcontextprotocol/core-internal/ext/skills'; -import type { RequestOptions } from '@modelcontextprotocol/core-internal/public'; +import type { ReadResourceResult, RequestOptions } from '@modelcontextprotocol/core-internal/public'; import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal/public'; import type * as z from 'zod/v4'; import type { CacheableRequestOptions, Client } from '../../client/client'; -import { clientSettings } from './clientInternals'; +import { clientInternals } from '../../client/client'; import type { InvalidSkillEntry } from './entries'; import { checkEntry, partitionEntries } from './entries'; import { SkillVerificationError } from './errors'; @@ -85,8 +85,8 @@ interface ReadTarget { } /** - * Resolves `ref` against the skill root, drops its fragment, and finds the file in the entry: by its exact spelling - * first, then by {@linkcode skillFileKey}, since servers percent-encode names differently (`Q&A.md`, `Q%26A.md`). + * Resolves `ref` against the skill root, drops its fragment, and finds the file in the entry by + * {@linkcode skillFileKey}, since servers percent-encode names differently (`Q&A.md`, `Q%26A.md`). * Throws `unlisted` for a ref that does not parse or names no file of the skill. */ function resolveTarget(skill: Skill, ref: string | undefined): ReadTarget { @@ -106,12 +106,7 @@ function resolveTarget(skill: Skill, ref: string | undefined): ReadTarget { if (!manifest && !isWithin(root, key)) throw notInSkill(); return { uri: key, listed: undefined, manifest }; } - const { resources } = skill; - const hash = uri.indexOf('#'); - const exact = hash === -1 ? uri : uri.slice(0, hash); - const listed = - resources.find(resource => canonicalUri(resource.uri) === exact) ?? - resources.find(resource => skillFileKey(root, resource.uri) === key); + const listed = skill.resources.find(resource => skillFileKey(root, resource.uri) === key); if (listed === undefined) throw notInSkill(); return { uri: listed.uri, listed, manifest }; } @@ -148,7 +143,7 @@ export class SkillsClient { get supported(): boolean { const capabilities = this.#client.getServerCapabilities(); if (skillsCapabilityOf(capabilities) === undefined) return false; - return !clientSettings(this.#client).enforceStrictCapabilities || capabilities?.resources !== undefined; + return !clientInternals.enforceStrictCapabilities(this.#client) || capabilities?.resources !== undefined; } /** The server serves `resources/directory/read`. */ @@ -164,25 +159,23 @@ export class SkillsClient { */ async list(params: { cursor?: string } = {}, options?: RequestOptions): Promise { if (!this.supported) { - if (clientSettings(this.#client).enforceStrictCapabilities) throw unsupported(SKILLS_LIST_METHOD); + if (clientInternals.enforceStrictCapabilities(this.#client)) throw unsupported(SKILLS_LIST_METHOD); // eslint-disable-next-line no-console console.debug(`SkillsClient.list() called but the server does not declare ${SKILLS_EXTENSION_ID} - returning an empty list`); return { skills: [], invalid: [] }; } - const pages = await this.#pages(SKILLS_LIST_METHOD, SkillsPageSchema, {}, params.cursor, options); - const { skills, invalid } = partitionEntries(pages.flatMap(page => page.skills)); + const page = await this.#pages(SKILLS_LIST_METHOD, SkillsPageSchema, {}, params.cursor, options, result => result.skills); + const { skills, invalid } = partitionEntries(page.skills); if (invalid.length > 0 && this.#options.strict) { const detail = invalid.map(entry => `#${entry.index} ${entry.uri ?? ''}: ${entry.reason}`).join('\n'); throw new SdkError(SdkErrorCode.InvalidResult, `skills/list returned invalid entries:\n${detail}`, { invalid }); } - const first = pages[0]!; - const last = pages.at(-1)!; return { skills, invalid, - ...(params.cursor !== undefined && last.nextCursor !== undefined && { nextCursor: last.nextCursor }), - ...(first.ttlMs !== undefined && { ttlMs: first.ttlMs }), - ...(first.cacheScope !== undefined && { cacheScope: first.cacheScope }) + ...(params.cursor !== undefined && page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + ...(page.ttlMs !== undefined && { ttlMs: page.ttlMs }), + ...(page.cacheScope !== undefined && { cacheScope: page.cacheScope }) }; } @@ -208,30 +201,35 @@ export class SkillsClient { * side percent-encodes the name) and is requested by the entry's URI; its * size and digest must match; * for the skill's own `SKILL.md` the frontmatter must also equal the - * entry's. A failed check on what may be a cached copy is fetched once - * more with `cacheMode: 'refresh'`. `"dynamic"` skills skip the listing, - * size and digest checks (`verified: false`) but not the frontmatter. - * Pass entries from {@linkcode SkillsClient.list} or {@linkcode SkillsClient.get}. + * entry's. A copy from the client's response cache that fails a check is + * fetched once more with `cacheMode: 'refresh'`. `"dynamic"` skills skip + * the listing, size and digest checks (`verified: false`) but not the + * frontmatter. Pass entries from {@linkcode SkillsClient.list} or + * {@linkcode SkillsClient.get}; an invalid entry throws a `TypeError` + * before any request. * * @throws {SkillVerificationError} when a check fails. */ async read(skill: Skill, ref?: string, options?: CacheableRequestOptions): Promise { if (!this.supported) throw unsupported('resources/read'); + const { problems } = checkEntry(skill); + if (problems.length > 0) throw new TypeError(`${skill.uri} is not a valid skill entry: ${problems.join('; ')}`); const target = resolveTarget(skill, ref); + const params = { uri: target.uri }; + const { result, cached } = await clientInternals.readResource(this.#client, params, options); try { - return await this.#readVerified(skill, target, options); + return await this.#verify(skill, target, result); } catch (error) { - const mayBeCached = options?.cacheMode === undefined || options.cacheMode === 'use'; - if (!(error instanceof SkillVerificationError) || !mayBeCached) throw error; - // The failing copy may have come from the response cache; the spec requires fetching it again. - return this.#readVerified(skill, target, { ...options, cacheMode: 'refresh' }); + if (!(error instanceof SkillVerificationError) || !cached) throw error; + // A cached copy that fails verification must be fetched again (SEP-2640). + return this.#verify(skill, target, await this.#client.readResource(params, { ...options, cacheMode: 'refresh' })); } } - async #readVerified(skill: Skill, target: ReadTarget, options: CacheableRequestOptions | undefined): Promise { + async #verify(skill: Skill, target: ReadTarget, result: ReadResourceResult): Promise { const { uri, listed } = target; - const result = await this.#client.readResource({ uri }, options); - const content = result.contents.find(item => canonicalUri(item.uri) === canonicalUri(uri)) ?? result.contents[0]; + const requested = canonicalUri(uri) ?? uri; + const content = result.contents.find(item => (canonicalUri(item.uri) ?? item.uri) === requested); if (content === undefined) throw new SdkError(SdkErrorCode.InvalidResult, `resources/read returned no contents for ${uri}`); const bytes = 'text' in content ? encodeUtf8(content.text) : decodeBlob(uri, content.blob); if (listed !== undefined) { @@ -288,38 +286,28 @@ export class SkillsClient { `Server does not declare directoryRead for ${SKILLS_EXTENSION_ID} (required for ${RESOURCES_DIRECTORY_READ_METHOD})` ); } - const pages = await this.#pages(RESOURCES_DIRECTORY_READ_METHOD, ReadResourceDirectoryResultSchema, { uri }, undefined, options); - return pages.flatMap(page => page.resources); + const page = await this.#pages( + RESOURCES_DIRECTORY_READ_METHOD, + ReadResourceDirectoryResultSchema, + { uri }, + undefined, + options, + result => result.resources + ); + return page.resources; } - /** One page (with `cursor`) or every page: stops at a repeated cursor, throws past `listMaxPages`. */ - async #pages( + /** One page (with `cursor`), or every page in one, walked as Client walks its own list verbs. */ + #pages( method: string, schema: z.ZodType, params: Record, cursor: string | undefined, - options: RequestOptions | undefined - ): Promise { + options: RequestOptions | undefined, + items: (page: T) => unknown[] + ): Promise { const fetchPage = (at: string | undefined): Promise => this.#client.request({ method, params: at === undefined ? params : { ...params, cursor: at } }, schema, options); - if (cursor !== undefined) return [await fetchPage(cursor)]; - const { listMaxPages } = clientSettings(this.#client); - const pages = [await fetchPage(undefined)]; - const seen = new Set(); - let next = pages[0]!.nextCursor; - while (next !== undefined && !seen.has(next)) { - if (listMaxPages !== 0 && pages.length >= listMaxPages) { - throw new SdkError( - SdkErrorCode.ListPaginationExceeded, - `${method}: exceeded listMaxPages (${listMaxPages}); server pagination did not terminate`, - { method, listMaxPages } - ); - } - seen.add(next); - const page = await fetchPage(next); - pages.push(page); - next = page.nextCursor; - } - return pages; + return cursor === undefined ? clientInternals.walkPages(this.#client, method, fetchPage, items) : fetchPage(cursor); } } diff --git a/packages/client/src/ext/skills/verify.ts b/packages/client/src/ext/skills/verify.ts index 40d8289123..b9b0f15649 100644 --- a/packages/client/src/ext/skills/verify.ts +++ b/packages/client/src/ext/skills/verify.ts @@ -5,8 +5,12 @@ const show = (value: unknown): string => (value === undefined ? 'nothing' : JSON const fieldOf = (path: string, key: string): string => (path === '' ? key : `${path}.${key}`); -/** `object[key]` when `key` is its own: a missing `constructor` or `__proto__` must read as nothing, not Object.prototype's. */ -const ownValue = (object: Record, key: string): unknown => (Object.hasOwn(object, key) ? object[key] : undefined); +/** Every field a reader of `object` sees, inherited ones included; Object.prototype's (`constructor`, `__proto__`) are not enumerable. */ +function fieldsOf(object: Record): Map { + const fields = new Map(); + for (const key in object) fields.set(key, object[key]); + return fields; +} /** * Field-level differences between a skill entry's frontmatter and the @@ -20,8 +24,9 @@ export function frontmatterDifferences(entry: unknown, parsed: unknown, path = ' return entry.flatMap((item, index) => frontmatterDifferences(item, parsed[index], `${path}[${index}]`)); } if (isPlainObject(entry) && isPlainObject(parsed)) { - const keys = [...new Set([...Object.keys(entry), ...Object.keys(parsed)])].toSorted(); - return keys.flatMap(key => frontmatterDifferences(ownValue(entry, key), ownValue(parsed, key), fieldOf(path, key))); + const [fromEntry, fromParsed] = [fieldsOf(entry), fieldsOf(parsed)]; + const keys = [...new Set([...fromEntry.keys(), ...fromParsed.keys()])].toSorted(); + return keys.flatMap(key => frontmatterDifferences(fromEntry.get(key), fromParsed.get(key), fieldOf(path, key))); } return entry === parsed ? [] : [`${label}: the entry has ${show(entry)}, SKILL.md has ${show(parsed)}`]; } diff --git a/packages/client/test/ext/skills/clientInternals.test.ts b/packages/client/test/ext/skills/clientInternals.test.ts deleted file mode 100644 index 63c27108ed..0000000000 --- a/packages/client/test/ext/skills/clientInternals.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { Client } from '../../../src/client/client'; -import { clientSettings } from '../../../src/ext/skills/clientInternals'; - -const INFO = { name: 'c', version: '1.0.0' }; - -describe('clientSettings', () => { - it('reads the private Client settings it relies on, and fails if they are renamed', () => { - expect(clientSettings(new Client(INFO))).toEqual({ enforceStrictCapabilities: false, listMaxPages: 64 }); - expect(clientSettings(new Client(INFO, { enforceStrictCapabilities: true, listMaxPages: 3 }))).toEqual({ - enforceStrictCapabilities: true, - listMaxPages: 3 - }); - const privates = new Client(INFO) as unknown as Record; - expect(privates).toHaveProperty('_enforceStrictCapabilities'); - expect(privates).toHaveProperty('_listMaxPages'); - }); -}); diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts index f7a9c98714..256cc1d49b 100644 --- a/packages/client/test/ext/skills/read.test.ts +++ b/packages/client/test/ext/skills/read.test.ts @@ -1,4 +1,4 @@ -import type { Skill } from '@modelcontextprotocol/core/ext/skills'; +import type { Skill, SkillResourceEntry } from '@modelcontextprotocol/core/ext/skills'; import { SdkErrorCode } from '@modelcontextprotocol/core-internal'; import { buildSkillEntry, encodeUtf8, joinUri, sha256Digest } from '@modelcontextprotocol/core-internal/ext/skills'; import { describe, expect, it, vi } from 'vitest'; @@ -99,31 +99,29 @@ describe('SkillsClient.read', () => { expect(reads(calls)).toEqual([overEncoded, overEncoded]); }); - it('prefers the exact spelling when an entry lists two spellings of one file', async () => { - const plain = `${PDF_ROOT}/references/Q&A.md`; - const encoded = `${PDF_ROOT}/references/Q%26A.md`; - const { skills, skill } = await publishedAs( - new Map([ - [MANIFEST, PDF_SKILL_MD], - [plain, '# plain\n'], - [encoded, '# encoded\n'] - ]) - ); - for (const ref of ['references/Q&A.md', 'references/Q&A.md#part']) { - expect(await skills.read(skill, ref)).toMatchObject({ uri: plain, text: '# plain\n' }); + it('refuses an invalid entry before any request: a file listed in two spellings, or outside the skill', async () => { + const { skills, skill, calls } = await connected({ files: { ...PDF_FILES, 'references/Q&A.md': '# Q&A\n' } }); + const listed = skill.resources as SkillResourceEntry[]; + const qa = listed.find(resource => resource.uri.endsWith('/Q&A.md'))!; + const twice: Skill = { ...skill, resources: [...listed, { ...qa, uri: `${PDF_ROOT}/references/Q%26A.md` }] }; + const outside: Skill = { ...skill, resources: [...listed, { ...qa, uri: 'skill://other-skill/Q&A.md' }] }; + for (const [entry, ref, reason] of [ + [twice, 'references/Q&A.md', /more than once/], + [outside, 'skill://other-skill/Q&A.md', /outside the skill directory/] + ] as const) { + const error = await skills.read(entry, ref).catch((error: unknown) => error); + expect(error).toBeInstanceOf(TypeError); + expect(error).toMatchObject({ message: expect.stringMatching(reason) }); } - expect(await skills.read(skill, 'references/Q%26A.md')).toMatchObject({ uri: encoded, text: '# encoded\n' }); + expect(reads(calls)).toEqual([]); }); it("checks SKILL.md's frontmatter however the ref spells it", async () => { - const alias = `${PDF_ROOT}/%53KILL.md`; - const { skills, skill } = await publishedAs( - new Map([ - [MANIFEST, PDF_SKILL_MD], - [alias, PDF_SKILL_MD.replace('Extract text and fill forms in PDF files.', 'Something else.')] - ]) - ); - await expect(skills.read(skill, '%53KILL.md')).rejects.toMatchObject({ reason: 'frontmatter', uri: alias }); + const { skills, skill, calls } = await connected(); + expect(await skills.read(skill, '%53KILL.md')).toMatchObject({ uri: MANIFEST, frontmatter: { name: 'pdf-processing' } }); + const tampered: Skill = { ...skill, frontmatter: { ...skill.frontmatter, description: 'Something else.' } }; + await expect(skills.read(tampered, '%53KILL.md')).rejects.toMatchObject({ reason: 'frontmatter', uri: MANIFEST }); + expect(reads(calls)).toEqual([MANIFEST, MANIFEST]); }); it('drops a fragment from the ref', async () => { @@ -197,6 +195,25 @@ describe('SkillsClient.read', () => { }); }); + it('sees fields either side inherits through a prototype', async () => { + const inheriting = (fields: object, inherited: object) => Object.assign(Object.create(inherited) as object, fields); + const { skills, skill } = await connected(); + const entry: Skill = { + ...skill, + frontmatter: inheriting(skill.frontmatter, { 'allowed-tools': 'Bash(*)' }) as Skill['frontmatter'] + }; + await expect(skills.read(entry)).rejects.toMatchObject({ + reason: 'frontmatter', + differences: ['allowed-tools: the entry has "Bash(*)", SKILL.md has nothing'] + }); + const parseFrontmatter = () => inheriting(skill.frontmatter, { 'allowed-tools': 'Bash(*)' }) as Record; + const parsing = await connected({ options: { parseFrontmatter } }); + await expect(parsing.skills.read(parsing.skill)).rejects.toMatchObject({ + reason: 'frontmatter', + differences: ['allowed-tools: the entry has nothing, SKILL.md has "Bash(*)"'] + }); + }); + it('fails closed on YAML outside the supported subset', async () => { const markdown = '---\nname: &n pdf-processing\ndescription: d\n---\n'; const bytes = encodeUtf8(markdown); @@ -266,13 +283,37 @@ describe('SkillsClient.read', () => { expect(reads(calls)).toEqual([FORMS, FORMS]); }); - it('gives up after that one extra fetch, and makes none under cacheMode bypass', async () => { - const { skills, skill, served, calls } = await connected(); - served.set(FORMS, FORMS_MD.toUpperCase()); - await expect(skills.read(skill, 'references/FORMS.md')).rejects.toMatchObject({ reason: 'digest' }); + it('gives up when the fresh copy fails as well', async () => { + const { skills, skill, served, calls } = await connected({ readTtlMs: 60_000 }); + await skills.read(skill, 'references/FORMS.md'); + served.set(FORMS, '# Forms v2\n'); + const other = await entryFor(PDF_ROOT, { ...PDF_FILES, 'references/FORMS.md': '# Forms v3\n' }); + await expect(skills.read(other, 'references/FORMS.md')).rejects.toMatchObject({ reason: 'digest' }); expect(reads(calls)).toEqual([FORMS, FORMS]); - await expect(skills.read(skill, 'references/FORMS.md', { cacheMode: 'bypass' })).rejects.toMatchObject({ reason: 'digest' }); - expect(reads(calls)).toHaveLength(3); + }); + + it('fetches no second copy when the failing one came from the server', async () => { + const { skills, skill, served, calls } = await connected({ readTtlMs: 60_000 }); + served.set(FORMS, FORMS_MD.toUpperCase()); + for (const cacheMode of [undefined, 'refresh', 'bypass'] as const) { + await expect(skills.read(skill, 'references/FORMS.md', { cacheMode })).rejects.toMatchObject({ reason: 'digest' }); + } + expect(reads(calls)).toEqual([FORMS, FORMS, FORMS]); + }); + + it('refuses contents for a URI other than the one requested', async () => { + const skill = await entryFor(PDF_ROOT, PDF_FILES); + const other = { uri: `${PDF_ROOT}/references/OTHER.md`, mimeType: 'text/markdown', text: FORMS_MD }; + const { client } = await scriptedClient(CAPABILITIES, { + ...skillsHandlers([skill], filesOf(PDF_ROOT, PDF_FILES)), + 'resources/read': () => ({ contents: [other], ttlMs: 0, cacheScope: 'private' }) + }); + const skills = new SkillsClient(client); + for (const target of [skill, { ...skill, resources: 'dynamic' } satisfies Skill]) { + const error = await skills.read(target, 'references/FORMS.md').catch((error: unknown) => error); + expect(error).not.toBeInstanceOf(SkillVerificationError); + expect(error).toMatchObject({ code: SdkErrorCode.InvalidResult, message: expect.stringContaining(FORMS) }); + } }); it('throws CapabilityNotSupported on a server without the extension', async () => { diff --git a/packages/client/test/ext/skills/skillsClient.test.ts b/packages/client/test/ext/skills/skillsClient.test.ts index ad5834025c..458cbda3f5 100644 --- a/packages/client/test/ext/skills/skillsClient.test.ts +++ b/packages/client/test/ext/skills/skillsClient.test.ts @@ -91,7 +91,7 @@ describe('SkillsClient.list', () => { }); it('returns an empty listing from a server without the extension, without asking it', async () => { - const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); const { client, calls } = await scriptedClient({ resources: {} }, {}); expect(await new SkillsClient(client).list()).toEqual({ skills: [], invalid: [] }); expect(calls).toEqual([]); @@ -103,13 +103,40 @@ describe('SkillsClient.list', () => { await expect(new SkillsClient(client).list()).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); }); - it('stops when a server repeats a cursor', async () => { + it('refuses an entry whose frontmatter has a __proto__ field', async () => { + const [alpha] = await threeSkills(); + // As a response arrives: JSON.parse keeps "__proto__" as an own field. + const wire: unknown = JSON.parse( + JSON.stringify(alpha).replace('"frontmatter":{', '"frontmatter":{"__proto__":{"allowed-tools":"Bash(*)"},') + ); + const { client } = await scriptedClient(CAPABILITIES, { + 'skills/list': () => ({ skills: [wire] }), + 'skills/get': () => ({ skill: wire }) + }); + const skills = new SkillsClient(client); + expect(await skills.list()).toEqual({ + skills: [], + invalid: [{ index: 0, uri: alpha!.uri, reason: expect.stringContaining('__proto__') }] + }); + await expect(skills.get(alpha!.uri)).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult }); + }); + + it('ends the walk on a page with the same items and cursor as the one before, as Client does', async () => { const [alpha] = await threeSkills(); const { client, calls } = await scriptedClient(CAPABILITIES, { 'skills/list': () => ({ skills: [alpha], nextCursor: 'same' }) }); - const listing = await new SkillsClient(client).list(); + expect(await new SkillsClient(client).list()).toEqual({ skills: [alpha], invalid: [] }); expect(methodCalls(calls, 'skills/list')).toHaveLength(2); - expect(listing.skills).toEqual([]); - expect(listing.invalid).toHaveLength(2); + }); + + it('throws ListPaginationExceeded on cursors that cycle, as Client does', async () => { + const entries = await threeSkills(); + const cycling: Handler = params => { + const at = params['cursor'] === 'A' ? 1 : params['cursor'] === 'B' ? 2 : 0; + return { skills: [entries[at]], nextCursor: at === 1 ? 'B' : 'A' }; + }; + const { client, calls } = await scriptedClient(CAPABILITIES, { 'skills/list': cycling }, { listMaxPages: 5 }); + await expect(new SkillsClient(client).list()).rejects.toMatchObject({ code: SdkErrorCode.ListPaginationExceeded }); + expect(methodCalls(calls, 'skills/list')).toHaveLength(5); }); it('gives up after listMaxPages on endless pagination', async () => { diff --git a/packages/client/test/ext/skills/verify.test.ts b/packages/client/test/ext/skills/verify.test.ts index b70a737fc2..480aacc0b3 100644 --- a/packages/client/test/ext/skills/verify.test.ts +++ b/packages/client/test/ext/skills/verify.test.ts @@ -36,4 +36,13 @@ describe('frontmatterDifferences', () => { // JSON.parse makes "__proto__" an own key; a lookup through the prototype would see Object.prototype, an empty object. expect(frontmatterDifferences(JSON.parse('{"__proto__":{}}'), {})).toEqual(['__proto__: the entry has {}, SKILL.md has nothing']); }); + + it('sees fields inherited through a prototype, as a reader of the object does', () => { + expect(frontmatterDifferences(Object.create({ 'allowed-tools': 'Bash(*)' }), {})).toEqual([ + 'allowed-tools: the entry has "Bash(*)", SKILL.md has nothing' + ]); + expect(frontmatterDifferences({ a: { b: 1 } }, { a: Object.create({ b: 1, c: 2 }) })).toEqual([ + 'a.c: the entry has nothing, SKILL.md has 2' + ]); + }); }); diff --git a/packages/core/src/ext/skills/schemas.ts b/packages/core/src/ext/skills/schemas.ts index 9bb877ad49..e581f889e7 100644 --- a/packages/core/src/ext/skills/schemas.ts +++ b/packages/core/src/ext/skills/schemas.ts @@ -19,10 +19,13 @@ export const SkillResourceEntrySchema = z.looseObject({ size: z.number().int().nonnegative() }); -export const SkillFrontmatterSchema = z.looseObject({ - name: z.string(), - description: z.string() -}); +export const SkillFrontmatterSchema = z + .looseObject({ + name: z.string(), + description: z.string() + }) + // Zod copies an own `__proto__` field by assignment, which makes it the prototype: fields no key listing shows. + .refine(frontmatter => Object.getPrototypeOf(frontmatter) === Object.prototype, 'frontmatter must not have a "__proto__" field'); export const SkillResourcesSchema = z.union([z.array(SkillResourceEntrySchema), z.literal('dynamic')]); diff --git a/packages/core/test/ext/skills/schemas.test.ts b/packages/core/test/ext/skills/schemas.test.ts index 7a417933a7..dab14124ce 100644 --- a/packages/core/test/ext/skills/schemas.test.ts +++ b/packages/core/test/ext/skills/schemas.test.ts @@ -7,9 +7,9 @@ import { ReadResourceDirectoryRequestParamsSchema, ReadResourceDirectoryResultSchema, SKILL_LIMITS, - SkillSchema, + SKILLS_EXTENSION_ID, skillsCapabilityOf, - SKILLS_EXTENSION_ID + SkillSchema } from '../../../src/ext/skills/index'; const digest = `sha256:${'a'.repeat(64)}`; @@ -32,6 +32,17 @@ describe('SkillSchema', () => { expect(parsed.frontmatter).toEqual({ name: 'git-workflow', description: 'd', license: 'MIT' }); }); + it('rejects a frontmatter "__proto__" field, which would otherwise become the prototype', () => { + for (const value of ['{"allowed-tools":"Bash(*)"}', 'null']) { + const frontmatter: unknown = JSON.parse(`{"name":"git-workflow","description":"d","__proto__":${value}}`); + const result = SkillSchema.safeParse(skill({ frontmatter })); + expect(result.success).toBe(false); + expect(result.error?.issues).toEqual([ + expect.objectContaining({ path: ['frontmatter'], message: expect.stringContaining('__proto__') }) + ]); + } + }); + it('accepts more files than SKILL_LIMITS (limits are policy, not schema)', () => { const resources = Array.from({ length: SKILL_LIMITS.files + 1 }, (_, index) => ({ uri: `skill://git-workflow/f${index}`, From 62fee61777b8880761570af961e1e5d636590405 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 16:34:52 +0300 Subject: [PATCH 35/45] revert(client): move the 2026-07-28 HTTP 404 method-not-found fix to its own PR This reverts commit 96a6d1e865155d4b9b59e9bce8e35a3a6cbb1016, with its patch changeset and era-table row. The fix is unrelated to skills and changes how a stable transport reports errors, so it gets its own review. The skills wire suite now expects the current behaviour on 2026-07-28: HTTP 404 with a -32601 body, surfaced as SdkHttpError. --- .../client-modern-404-method-not-found.md | 5 ---- docs/protocol-versions.md | 26 ++++++++-------- packages/client/src/client/streamableHttp.ts | 15 +++------- .../client/test/client/streamableHttp.test.ts | 30 +------------------ .../test/skills/skillsWire.test.ts | 6 +++- 5 files changed, 23 insertions(+), 59 deletions(-) delete mode 100644 .changeset/client-modern-404-method-not-found.md diff --git a/.changeset/client-modern-404-method-not-found.md b/.changeset/client-modern-404-method-not-found.md deleted file mode 100644 index 83513f796e..0000000000 --- a/.changeset/client-modern-404-method-not-found.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -`StreamableHTTPClientTransport`: on a 2026-07-28 connection, an HTTP `404` whose body is a JSON-RPC error response for the request (such as `-32601` for a method the server does not implement) now rejects the request with that error as a `ProtocolError` instead of `SdkHttpError(404)`, as a `400` with a JSON-RPC error body already did. 2025-era connections are unchanged. diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index e098203ed5..02f65efd60 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -161,19 +161,19 @@ On stdio, `serveStdio(factory)` from `@modelcontextprotocol/server/stdio` is the This table is the only copy of the era differences in these docs. `getProtocolEra()` on the client and the factory's `era` on the server tell you which column you are in. -| Axis | 2025 era (`'legacy'`, `2024-10-07` … `2025-11-25`) | 2026 era (`'modern'`, `2026-07-28`) | -| ------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------ | -| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | -| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | -| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | -| Client identity on the server | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | -| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` | `return inputRequired(...)` from the handler | -| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | -| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | -| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `logLevel` `_meta` envelope key (absent = no logs) | -| HTTP `400`/`404` with a JSON-RPC error body | `SdkHttpError` | `ProtocolError`, delivered in-band | -| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | -| Liveness check | `client.ping()` | not defined — outbound call rejects per the era-mismatch row | +| Axis | 2025 era (`'legacy'`, `2024-10-07` … `2025-11-25`) | 2026 era (`'modern'`, `2026-07-28`) | +| ------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | +| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | +| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | +| Client identity on the server | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | +| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` | `return inputRequired(...)` from the handler | +| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | +| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | +| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `logLevel` `_meta` envelope key (absent = no logs) | +| HTTP `400` with a JSON-RPC error body | `SdkHttpError` | `ProtocolError`, delivered in-band | +| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | +| Liveness check | `client.ping()` | not defined — outbound call rejects per the era-mismatch row | ## Separate deprecation from era diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index f3ac6aa9e7..091674e101 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1126,29 +1126,22 @@ export class StreamableHTTPClientTransport implements Transport { // SEP-2243 (and the rest of the inbound validation ladder) // emit ladder rejections as HTTP 400 carrying a JSON-RPC error - // response body, and an unimplemented method is answered - // HTTP 404 with a -32601 body (the 2026-07-28 Streamable HTTP - // MUST; the body is what tells it apart from a legacy 404). - // Surface those in-band so `Protocol._onresponse` + // response body. Surface those in-band so `Protocol._onresponse` // converts them to a typed `ProtocolError` matched to the // pending request id — instead of an opaque transport error. - // Any 400/404 whose body is not a well-formed JSON-RPC error + // Any 400 whose body is not a well-formed JSON-RPC error // response (or whose id does not match an outstanding request) // still falls through to the generic `SdkHttpError`. // // Modern-era only: gated on the outbound message carrying a // 2026-07-28 envelope claim (the same gate the body-derived // `mcp-method`/`mcp-name` headers use), so a legacy-era - // exchange keeps surfacing 400/404 as `SdkHttpError` exactly as + // exchange keeps surfacing 400 as `SdkHttpError` exactly as // before — the changeset's "legacy-era paths are unchanged" // claim stays true and existing // `e instanceof SdkHttpError && e.status === 400` callers do // not silently stop matching. - if ( - (response.status === 400 || response.status === 404) && - typeof text === 'string' && - this._isModernEnvelopedRequest(message) - ) { + if (response.status === 400 && typeof text === 'string' && this._isModernEnvelopedRequest(message)) { try { const parsed = JSONRPCMessageSchema.parse(JSON.parse(text)); const requests = (Array.isArray(message) ? message : [message]).filter(m => isJSONRPCRequest(m)); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 50e941db10..17fd2df276 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1,5 +1,5 @@ import type { JSONRPCMessage, JSONRPCRequest, OAuthTokens } from '@modelcontextprotocol/core-internal'; -import { OAuthError, OAuthErrorCode, PROTOCOL_VERSION_META_KEY, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal'; +import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal'; import type { Mock, Mocked } from 'vitest'; import type { OAuthClientProvider } from '../../src/client/auth'; @@ -369,34 +369,6 @@ describe('StreamableHTTPClientTransport', () => { expect(errorSpy).toHaveBeenCalled(); }); - it('delivers a 2026-07-28 HTTP 404 method-not-found JSON-RPC error in-band; legacy still throws SdkHttpError', async () => { - // 2026-07-28 Streamable HTTP: a server that does not implement the method MUST answer 404 with a - // -32601 JSON-RPC error, and that body is what tells it apart from a legacy server's 404. - const errorBody = { jsonrpc: '2.0', id: 1, error: { code: -32_601, message: 'Method not found' } }; - const fetch = vi.fn(async () => Response.json(errorBody, { status: 404 })); - const tx = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { fetch }); - const seen: JSONRPCMessage[] = []; - tx.onmessage = message => seen.push(message); - await tx.start(); - - const params = { uri: 'skill://notes' }; - await expect( - tx.send({ - jsonrpc: '2.0', - id: 1, - method: 'resources/directory/read', - params: { ...params, _meta: { [PROTOCOL_VERSION_META_KEY]: '2026-07-28' } } - }) - ).resolves.toBeUndefined(); - expect(seen).toEqual([errorBody]); - - await expect(tx.send({ jsonrpc: '2.0', id: 1, method: 'resources/directory/read', params })).rejects.toMatchObject({ - code: SdkErrorCode.ClientHttpNotImplemented, - status: 404 - }); - await tx.close(); - }); - it('should handle non-streaming JSON response', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0', diff --git a/test/integration/test/skills/skillsWire.test.ts b/test/integration/test/skills/skillsWire.test.ts index 8a3f88dab1..9ee83e3780 100644 --- a/test/integration/test/skills/skillsWire.test.ts +++ b/test/integration/test/skills/skillsWire.test.ts @@ -144,7 +144,11 @@ describe.each([LEGACY, MODERN])('skills wire behaviour on %s', revision => { expect(client.getServerCapabilities()?.extensions).toEqual({ [EXTENSION]: {} }); const { resources } = await raw(client, 'resources/list'); expect(resources).toEqual([]); - await expect(raw(client, 'resources/directory/read', { uri: 'skill://notes' })).rejects.toMatchObject({ code: -32_601 }); + const directoryRead = raw(client, 'resources/directory/read', { uri: 'skill://notes' }); + // 2026-07-28 Streamable HTTP answers an unimplemented method with HTTP 404 and a -32601 body; the client transport surfaces a 404 as SdkHttpError. + await (revision === MODERN + ? expect(directoryRead).rejects.toMatchObject({ data: { status: 404, text: expect.stringContaining('-32601') } }) + : expect(directoryRead).rejects.toMatchObject({ code: -32_601 })); }); it('lists what listResources returns, and the host verifies source-built skills', async () => { From e99022e7081d67781fee80c21bca061ea55a7423 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 17:27:22 +0300 Subject: [PATCH 36/45] fix(skills): trim trailing slashes without a backtracking regex CodeQL (js/polynomial-redos) flagged /\/+$/ on baseUri input in joinUri: it takes quadratic time on a long run of '/'. trimTrailingSlashes does the same with a loop, and SkillsExtension's baseUri handling uses it too. --- packages/core-internal/src/ext/skills/index.ts | 3 ++- packages/core-internal/src/ext/skills/uri.ts | 9 ++++++++- .../core-internal/test/ext/skills/uri.test.ts | 16 +++++++++++++++- packages/server/src/ext/skills/extension.ts | 10 ++++++++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/core-internal/src/ext/skills/index.ts b/packages/core-internal/src/ext/skills/index.ts index cd8a46f5df..c62253a065 100644 --- a/packages/core-internal/src/ext/skills/index.ts +++ b/packages/core-internal/src/ext/skills/index.ts @@ -21,5 +21,6 @@ export { lastSegmentOf, resolveSkillRef, skillFileKey, - skillRootUri + skillRootUri, + trimTrailingSlashes } from './uri'; diff --git a/packages/core-internal/src/ext/skills/uri.ts b/packages/core-internal/src/ext/skills/uri.ts index 3ff7de62d3..a60143d234 100644 --- a/packages/core-internal/src/ext/skills/uri.ts +++ b/packages/core-internal/src/ext/skills/uri.ts @@ -11,9 +11,16 @@ export function encodeSegment(segment: string): string { return encodePathText(segment); } +/** `text` without its trailing `/`s; a loop, since `/\/+$/` backtracks polynomially on a long run of `/`. */ +export function trimTrailingSlashes(text: string): string { + let end = text.length; + while (end > 0 && text.codePointAt(end - 1) === 0x2f) end--; + return text.slice(0, end); +} + /** `base` (`scheme://`, or a URI without a trailing slash) + `/`-joined encoded segments; an authority is encoded strictly. */ export function joinUri(base: string, segments: readonly string[]): string { - if (!base.endsWith('://')) return `${base.replace(/\/+$/, '')}/${segments.map(segment => encodeSegment(segment)).join('/')}`; + if (!base.endsWith('://')) return `${trimTrailingSlashes(base)}/${segments.map(segment => encodeSegment(segment)).join('/')}`; const [authority, ...path] = segments; if (authority === undefined) return base; return [base + encodeURIComponent(authority), ...path.map(segment => encodeSegment(segment))].join('/'); diff --git a/packages/core-internal/test/ext/skills/uri.test.ts b/packages/core-internal/test/ext/skills/uri.test.ts index 2167f97469..79e18bb96a 100644 --- a/packages/core-internal/test/ext/skills/uri.test.ts +++ b/packages/core-internal/test/ext/skills/uri.test.ts @@ -10,7 +10,8 @@ import { lastSegmentOf, resolveSkillRef, skillFileKey, - skillRootUri + skillRootUri, + trimTrailingSlashes } from '../../../src/ext/skills/uri'; describe('uri helpers', () => { @@ -19,6 +20,19 @@ describe('uri helpers', () => { expect(uri).toBe('skill://acme/refunds/templates/Purchase%20Order.md'); expect(isRoundTripUri(uri)).toBe(true); expect(joinUri('github://owner/repo/skills/', ['x', 'SKILL.md'])).toBe('github://owner/repo/skills/x/SKILL.md'); + expect(joinUri('github://owner/repo/skills///', ['x'])).toBe('github://owner/repo/skills/x'); + }); + + it('trims only trailing slashes, in linear time on a long run of them', () => { + expect(trimTrailingSlashes('a/b//')).toBe('a/b'); + expect(trimTrailingSlashes('a/b')).toBe('a/b'); + expect(trimTrailingSlashes('///')).toBe(''); + expect(trimTrailingSlashes('')).toBe(''); + // CodeQL js/polynomial-redos: `/\/+$/` takes ~4 s on this input; the loop takes well under 1 ms. + const slashes = `x${'/'.repeat(100_000)}y`; + const startTime = Date.now(); + expect(trimTrailingSlashes(slashes)).toBe(slashes); + expect(Date.now() - startTime).toBeLessThan(100); }); it.each(['Q&A.md', 'c++.md', '[draft].md', 'me@home.md', 'a,b;c=d$e|f!(g)*~h.md', 'Purchase Order.md', 'Remboursé.md', 'x{y}z.md'])( diff --git a/packages/server/src/ext/skills/extension.ts b/packages/server/src/ext/skills/extension.ts index 2ce23867b2..b15cbbc845 100644 --- a/packages/server/src/ext/skills/extension.ts +++ b/packages/server/src/ext/skills/extension.ts @@ -17,7 +17,13 @@ import { ProtocolErrorCode, ResourceNotFoundError } from '@modelcontextprotocol/core-internal'; -import { canonicalUri, encodeBase64, joinUri, skillEntryProblems } from '@modelcontextprotocol/core-internal/ext/skills'; +import { + canonicalUri, + encodeBase64, + joinUri, + skillEntryProblems, + trimTrailingSlashes +} from '@modelcontextprotocol/core-internal/ext/skills'; import type { McpServer } from '../../server/mcp'; import { ResourceTemplate } from '../../server/mcp'; @@ -93,7 +99,7 @@ export class SkillsExtension { // Canonical, like the request URIs that the template and the listResources filter are matched against. const canonical = SCHEME_URI.test(baseUri) ? canonicalUri(baseUri) : undefined; if (canonical === undefined) throw new TypeError(`baseUri must be a "scheme://…" URI (got ${JSON.stringify(baseUri)})`); - this.#baseRoot = canonical.endsWith('://') ? canonical : `${canonical.replace(/\/+$/, '')}/`; + this.#baseRoot = canonical.endsWith('://') ? canonical : `${trimTrailingSlashes(canonical)}/`; this.#source = options.source; this.#trusted = trustedSources.has(options.source); this.#cacheHint = options.cacheHint; From 5d725945c9affe9a498201025360468bdb0abcd3 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 17:27:27 +0300 Subject: [PATCH 37/45] test(skills): write in-code markdown as template literals Multi-line SKILL.md and markdown strings in the docs companions, test fixtures, conformance fixture and integration suites read as the files they stand for. Byte-identical; parser corpora, the BOM/CRLF fixture and single-line strings stay escaped. --- docs/servers/skills.md | 14 ++++- examples/guides/clients/skills.examples.ts | 22 +++++-- examples/guides/servers/skills.examples.ts | 14 ++++- packages/client/test/ext/skills/fixtures.ts | 19 ++++-- packages/client/test/ext/skills/read.test.ts | 9 ++- .../server/test/ext/skills/catalog.test.ts | 21 ++++++- .../test/ext/skills/catalogBuild.test.ts | 58 ++++++++++++++++--- .../server/test/ext/skills/extension.test.ts | 19 ++++-- packages/server/test/ext/skills/fixtures.ts | 17 +++++- packages/server/test/ext/skills/node.test.ts | 7 ++- test/conformance/src/skills.ts | 25 ++++++-- .../test/server/cloudflareWorkers.test.ts | 7 ++- test/integration/test/skills/skillTree.ts | 22 +++++-- .../test/skills/skillsEndToEnd.test.ts | 9 ++- .../test/skills/skillsWire.test.ts | 14 ++++- 15 files changed, 231 insertions(+), 46 deletions(-) diff --git a/docs/servers/skills.md b/docs/servers/skills.md index b6193b0530..b3388d42c9 100644 --- a/docs/servers/skills.md +++ b/docs/servers/skills.md @@ -92,7 +92,12 @@ Pass the files to `memorySkillFiles` where there is no file system, such as Work ```ts source="../../examples/guides/servers/skills.examples.ts#memorySkillFiles" import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; -const RELEASE_NOTES_MD = '---\nname: release-notes\ndescription: Write release notes from merged pull requests.\n---\n# Release notes\n'; +const RELEASE_NOTES_MD = `--- +name: release-notes +description: Write release notes from merged pull requests. +--- +# Release notes +`; const inline = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': RELEASE_NOTES_MD })); ``` @@ -142,7 +147,12 @@ Build entries with `skillEntry` and serve them from a `SkillSource` when the cat import type { SkillSource } from '@modelcontextprotocol/server/ext/skills'; import { skillEntry } from '@modelcontextprotocol/server/ext/skills'; -const ON_CALL_MD = '---\nname: on-call\ndescription: Handle a page while on call.\n---\n# On call\n'; +const ON_CALL_MD = `--- +name: on-call +description: Handle a page while on call. +--- +# On call +`; const onCall = await skillEntry('on-call', { 'SKILL.md': ON_CALL_MD }); const source: SkillSource = { diff --git a/examples/guides/clients/skills.examples.ts b/examples/guides/clients/skills.examples.ts index 7dbd7be942..d254eda977 100644 --- a/examples/guides/clients/skills.examples.ts +++ b/examples/guides/clients/skills.examples.ts @@ -16,10 +16,24 @@ import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ const catalog = await SkillsExtension.load( memorySkillFiles({ - 'git-workflow/SKILL.md': - '---\nname: git-workflow\ndescription: Branching and review conventions.\n---\n# Git workflow\n\nGo through references/REVIEW.md.\n', - 'git-workflow/references/REVIEW.md': '# Review checklist\n\n- Tests pass.\n', - 'acme/billing/refunds/SKILL.md': '---\nname: refunds\ndescription: Process customer refunds.\n---\n# Refunds\n', + 'git-workflow/SKILL.md': `--- +name: git-workflow +description: Branching and review conventions. +--- +# Git workflow + +Go through references/REVIEW.md. +`, + 'git-workflow/references/REVIEW.md': `# Review checklist + +- Tests pass. +`, + 'acme/billing/refunds/SKILL.md': `--- +name: refunds +description: Process customer refunds. +--- +# Refunds +`, 'acme/billing/refunds/templates/refund-email.md': 'Your refund is on its way.\n' }) ); diff --git a/examples/guides/servers/skills.examples.ts b/examples/guides/servers/skills.examples.ts index a627cf62e7..ecef49c604 100644 --- a/examples/guides/servers/skills.examples.ts +++ b/examples/guides/servers/skills.examples.ts @@ -56,7 +56,12 @@ if (changes.listChanged) handler.notify.resourcesChanged(); //#region memorySkillFiles import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; -const RELEASE_NOTES_MD = '---\nname: release-notes\ndescription: Write release notes from merged pull requests.\n---\n# Release notes\n'; +const RELEASE_NOTES_MD = `--- +name: release-notes +description: Write release notes from merged pull requests. +--- +# Release notes +`; const inline = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': RELEASE_NOTES_MD })); //#endregion memorySkillFiles @@ -94,7 +99,12 @@ const fromBucket = await SkillsExtension.load( import type { SkillSource } from '@modelcontextprotocol/server/ext/skills'; import { skillEntry } from '@modelcontextprotocol/server/ext/skills'; -const ON_CALL_MD = '---\nname: on-call\ndescription: Handle a page while on call.\n---\n# On call\n'; +const ON_CALL_MD = `--- +name: on-call +description: Handle a page while on call. +--- +# On call +`; const onCall = await skillEntry('on-call', { 'SKILL.md': ON_CALL_MD }); const source: SkillSource = { diff --git a/packages/client/test/ext/skills/fixtures.ts b/packages/client/test/ext/skills/fixtures.ts index b74cda1390..a1a7cded0f 100644 --- a/packages/client/test/ext/skills/fixtures.ts +++ b/packages/client/test/ext/skills/fixtures.ts @@ -6,10 +6,21 @@ import { buildSkillEntry, encodeSegment, encodeUtf8 } from '@modelcontextprotoco export const CAPABILITIES: ServerCapabilities = { resources: {}, extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: true } } }; export const PDF_ROOT = 'skill://pdf-processing'; -export const PDF_SKILL_MD = - '---\nname: pdf-processing\ndescription: Extract text and fill forms in PDF files.\nmetadata:\n author: acme\n version: "1.0"\n---\n' + - '# PDF processing\n\nSee [the forms guide](references/FORMS.md).\n'; -export const FORMS_MD = '# Forms\n\nFill the fields in order.\n'; +export const PDF_SKILL_MD = `--- +name: pdf-processing +description: Extract text and fill forms in PDF files. +metadata: + author: acme + version: "1.0" +--- +# PDF processing + +See [the forms guide](references/FORMS.md). +`; +export const FORMS_MD = `# Forms + +Fill the fields in order. +`; export const PDF_FILES: Readonly> = { 'SKILL.md': PDF_SKILL_MD, 'references/FORMS.md': FORMS_MD, diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts index 256cc1d49b..5f73d5542e 100644 --- a/packages/client/test/ext/skills/read.test.ts +++ b/packages/client/test/ext/skills/read.test.ts @@ -257,7 +257,14 @@ describe('SkillsClient.read', () => { it("treats a nested skill's SKILL.md as an ordinary file of the enclosing skill", async () => { const { skills, skill } = await connected({ - files: { ...PDF_FILES, 'forms/SKILL.md': '---\nname: forms\ndescription: Nested.\n---\n' } + files: { + ...PDF_FILES, + 'forms/SKILL.md': `--- +name: forms +description: Nested. +--- +` + } }); const file = await skills.read(skill, 'forms/SKILL.md'); expect(file).toMatchObject({ verified: true }); diff --git a/packages/server/test/ext/skills/catalog.test.ts b/packages/server/test/ext/skills/catalog.test.ts index 505e3cebe2..9595df52f3 100644 --- a/packages/server/test/ext/skills/catalog.test.ts +++ b/packages/server/test/ext/skills/catalog.test.ts @@ -21,7 +21,11 @@ async function loaded(options: SkillCatalogOptions = {}) { const skillUris = (result: Record): string[] => (result['skills'] as Skill[]).map(skill => skill.uri); const firstContent = (result: Record): Record | undefined => (result['contents'] as Array>)[0]; -const NEW_SKILL = '---\nname: aaa\ndescription: Sorts first.\n---\n'; +const NEW_SKILL = `--- +name: aaa +description: Sorts first. +--- +`; describe('SkillCatalog serving', () => { it('loads every skill sorted by URI and declares directoryRead with the skill:// template', async () => { @@ -198,7 +202,14 @@ describe('SkillCatalog serving', () => { }); it('freezes nested frontmatter too', async () => { - const skillMd = '---\nname: owned\ndescription: d\nmetadata:\n owner: billing\ntags: [a, b]\n---\n'; + const skillMd = `--- +name: owned +description: d +metadata: + owner: billing +tags: [a, b] +--- +`; const catalog = await SkillsExtension.load(memorySkillFiles({ 'owned/SKILL.md': skillMd })); const frontmatter = catalog.entries[0]!.frontmatter as unknown as { metadata: Record; tags: string[] }; expect(() => { @@ -252,7 +263,11 @@ describe('SkillCatalog.reload', () => { it('keeps serving the old catalog when a reload fails', async () => { const { catalog, files, mcp } = await loaded({ strict: true }); - files['git-workflow/SKILL.md'] = '---\nname: renamed\ndescription: d\n---\n'; + files['git-workflow/SKILL.md'] = `--- +name: renamed +description: d +--- +`; await expect(catalog.reload()).rejects.toBeInstanceOf(SkillCatalogError); expect(catalog.entries.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); expect(await resultOf(mcp, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } }); diff --git a/packages/server/test/ext/skills/catalogBuild.test.ts b/packages/server/test/ext/skills/catalogBuild.test.ts index 302bd7b30c..3efbe5a54b 100644 --- a/packages/server/test/ext/skills/catalogBuild.test.ts +++ b/packages/server/test/ext/skills/catalogBuild.test.ts @@ -48,7 +48,11 @@ const byPath = (a: SkillWarning, b: SkillWarning): number => (a.path < b.path ? const names = (entries: readonly SkillFileEntry[]): string[] => entries.map(entry => entry.name).toSorted(); const text = (bytes: Uint8Array): string => new TextDecoder().decode(bytes); -const INVALID = '---\nname: other\ndescription: d\n---\n'; +const INVALID = `--- +name: other +description: d +--- +`; const NOT_FOLLOWED = /symlinks and other special files are not followed; copy the file instead/; const UNREPRESENTABLE = /cannot be represented in a URI/; @@ -123,9 +127,17 @@ describe('buildCatalogSnapshot', () => { it('encodes a top-level folder strictly, since it is the URI authority', async () => { const files = { - 'me@home/refunds/SKILL.md': '---\nname: refunds\ndescription: d\n---\n', + 'me@home/refunds/SKILL.md': `--- +name: refunds +description: d +--- +`, 'me@home/refunds/references/Q&A.md': '# Q&A\n', - '[draft]/notes/SKILL.md': '---\nname: notes\ndescription: d\n---\n' + '[draft]/notes/SKILL.md': `--- +name: notes +description: d +--- +` }; const { options, warnings } = optionsWith(); const snapshot = await buildCatalogSnapshot(memorySkillFiles(files), options); @@ -152,8 +164,16 @@ describe('buildCatalogSnapshot', () => { const files = { ...fixtureFiles(), 'bad/SKILL.md': INVALID, - 'broken/SKILL.md': '---\nname: &a broken\ndescription: d\n---\n', - 'SKILL.md': '---\nname: root\ndescription: d\n---\n', + 'broken/SKILL.md': `--- +name: &a broken +description: d +--- +`, + 'SKILL.md': `--- +name: root +description: d +--- +`, 'notes/skill.md': '# not a skill' }; const { options, warnings } = optionsWith(); @@ -205,7 +225,11 @@ describe('buildCatalogSnapshot', () => { it('skips a skill with a name that cannot be represented in a URI, such as a lone surrogate', async () => { const memory = memorySkillFiles({ ...fixtureFiles(), - 'bad/SKILL.md': '---\nname: bad\ndescription: d\n---\n', + 'bad/SKILL.md': `--- +name: bad +description: d +--- +`, 'bad/x\uD800.md': 'x' }); // An empty folder too: no file below it carries the name, yet the catalog lists the folder. @@ -222,7 +246,15 @@ describe('buildCatalogSnapshot', () => { }); it('collects every problem into one SkillCatalogError when strict', async () => { - const files = { ...fixtureFiles(), 'bad/SKILL.md': INVALID, 'SKILL.md': '---\nname: r\ndescription: d\n---\n' }; + const files = { + ...fixtureFiles(), + 'bad/SKILL.md': INVALID, + 'SKILL.md': `--- +name: r +description: d +--- +` + }; const error = await buildCatalogSnapshot(memorySkillFiles(files), optionsWith({ strict: true }).options).catch( (error: unknown) => error ); @@ -306,8 +338,16 @@ describe('memorySkillFiles', () => { const before = await buildCatalogSnapshot(files, optionsWith().options); expect(before.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, GIT]); delete record['git-workflow/SKILL.md']; - record['deploy/SKILL.md'] = '---\nname: deploy\ndescription: Deploy a release.\n---\n'; - record['acme/billing/refunds/SKILL.md'] = '---\nname: refunds\ndescription: Refund workflow, revised.\n---\n'; + record['deploy/SKILL.md'] = `--- +name: deploy +description: Deploy a release. +--- +`; + record['acme/billing/refunds/SKILL.md'] = `--- +name: refunds +description: Refund workflow, revised. +--- +`; const after = await buildCatalogSnapshot(files, optionsWith().options); expect(after.ordered.map(skill => skill.uri)).toEqual([REFUNDS, ESCALATION, 'skill://deploy/SKILL.md']); expect(after.skills.get(REFUNDS)?.frontmatter.description).toBe('Refund workflow, revised.'); diff --git a/packages/server/test/ext/skills/extension.test.ts b/packages/server/test/ext/skills/extension.test.ts index 90aa267ff9..33b06c1d81 100644 --- a/packages/server/test/ext/skills/extension.test.ts +++ b/packages/server/test/ext/skills/extension.test.ts @@ -305,7 +305,12 @@ describe('attach', () => { describe('skillEntry', () => { it('builds a valid entry from files with real digests', async () => { - const skillMd = '---\nname: refunds\ndescription: Refund workflow.\n---\n# Refunds\n'; + const skillMd = `--- +name: refunds +description: Refund workflow. +--- +# Refunds +`; const skill = await skillEntry('acme/refunds', { 'SKILL.md': skillMd, 'references/policy.md': '# Policy\n' }); expect(skill.uri).toBe('skill://acme/refunds/SKILL.md'); expect(skill.resources).toContainEqual({ @@ -316,9 +321,15 @@ describe('skillEntry', () => { }); it('throws SkillCatalogError for an invalid skill and TypeError for an escaping path', async () => { - await expect(skillEntry('acme/refunds', { 'SKILL.md': '---\nname: other\ndescription: d\n---\n' })).rejects.toBeInstanceOf( - SkillCatalogError - ); + await expect( + skillEntry('acme/refunds', { + 'SKILL.md': `--- +name: other +description: d +--- +` + }) + ).rejects.toBeInstanceOf(SkillCatalogError); await expect(skillEntry('acme/../x', { 'SKILL.md': '' })).rejects.toBeInstanceOf(TypeError); }); }); diff --git a/packages/server/test/ext/skills/fixtures.ts b/packages/server/test/ext/skills/fixtures.ts index b924cb023e..3c670fdb8d 100644 --- a/packages/server/test/ext/skills/fixtures.ts +++ b/packages/server/test/ext/skills/fixtures.ts @@ -1,8 +1,19 @@ -export const GIT_SKILL_MD = - '---\nname: git-workflow\ndescription: Branching and review conventions.\n---\n# Git workflow\n\nSee references/REVIEW.md.\n'; +export const GIT_SKILL_MD = `--- +name: git-workflow +description: Branching and review conventions. +--- +# Git workflow + +See references/REVIEW.md. +`; /** Windows-authored: UTF-8 BOM and CRLF line endings. */ export const REFUNDS_SKILL_MD = '---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\n# Refunds\r\n'; -export const ESCALATION_SKILL_MD = '---\nname: escalation\ndescription: Escalate a refund.\n---\n# Escalation\n'; +export const ESCALATION_SKILL_MD = `--- +name: escalation +description: Escalate a refund. +--- +# Escalation +`; export const PNG = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]); export const GIT = 'skill://git-workflow/SKILL.md'; diff --git a/packages/server/test/ext/skills/node.test.ts b/packages/server/test/ext/skills/node.test.ts index 7235b482cf..427b245a89 100644 --- a/packages/server/test/ext/skills/node.test.ts +++ b/packages/server/test/ext/skills/node.test.ts @@ -107,7 +107,12 @@ describe('loadSkills', () => { try { await writeFile( path.join(outside, 'SKILL.md'), - '---\nname: linked-skill\ndescription: Lives outside the root.\n---\n# Linked\n' + `--- +name: linked-skill +description: Lives outside the root. +--- +# Linked +` ); await symlink(outside, path.join(root, 'linked-skill'), 'dir'); const warnings: SkillWarning[] = []; diff --git a/test/conformance/src/skills.ts b/test/conformance/src/skills.ts index 7e1dde4b4a..f2cce77395 100644 --- a/test/conformance/src/skills.ts +++ b/test/conformance/src/skills.ts @@ -7,12 +7,27 @@ import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ export const conformanceSkills = await SkillsExtension.load( memorySkillFiles({ - 'pdf-processing/SKILL.md': - '---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents.\nmetadata:\n author: mcp-conformance\n---\n' + - '# PDF processing\n\nRead references/FORMS.md before filling a form.\n', - 'pdf-processing/references/FORMS.md': '# Forms\n\nFill the fields from top to bottom.\n', + 'pdf-processing/SKILL.md': `--- +name: pdf-processing +description: Extract, fill, and assemble PDF documents. +metadata: + author: mcp-conformance +--- +# PDF processing + +Read references/FORMS.md before filling a form. +`, + 'pdf-processing/references/FORMS.md': `# Forms + +Fill the fields from top to bottom. +`, 'pdf-processing/scripts/extract.py': 'print("extract")\n', - 'acme/billing/refunds/SKILL.md': '---\nname: refunds\ndescription: Process customer refunds.\n---\n# Refunds\n', + 'acme/billing/refunds/SKILL.md': `--- +name: refunds +description: Process customer refunds. +--- +# Refunds +`, 'acme/billing/refunds/templates/refund-email.md': 'Your refund is on its way.\n' }), { pageSize: 1, strict: true } diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index ad42938e31..a30831ff67 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -29,7 +29,12 @@ const SHUTDOWN_GRACE_MS = 5000; const SERVER_VERSION_NONCE = randomUUID(); /** The skill the worker serves on `/skills`, from memory (Workers have no file system). */ -const RELEASE_NOTES_MD = '---\nname: release-notes\ndescription: Write release notes from merged pull requests.\n---\n# Release notes\n'; +const RELEASE_NOTES_MD = `--- +name: release-notes +description: Write release notes from merged pull requests. +--- +# Release notes +`; /** * The workspace's own wrangler installation (pinned by pnpm-lock). Running it directly — diff --git a/test/integration/test/skills/skillTree.ts b/test/integration/test/skills/skillTree.ts index 6a7fc587f0..b7e5dee99c 100644 --- a/test/integration/test/skills/skillTree.ts +++ b/test/integration/test/skills/skillTree.ts @@ -11,13 +11,27 @@ const PNG = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]); * no formatter or git line-ending rule can touch the bytes. */ export const SKILL_TREE: Readonly> = { - 'git-workflow/SKILL.md': - '---\nname: git-workflow\ndescription: Branching and review conventions.\n---\n# Git workflow\n\nRead references/REVIEW.md before opening a PR.\n', - 'git-workflow/references/REVIEW.md': '# Review\n\n- tests pass\n', + 'git-workflow/SKILL.md': `--- +name: git-workflow +description: Branching and review conventions. +--- +# Git workflow + +Read references/REVIEW.md before opening a PR. +`, + 'git-workflow/references/REVIEW.md': `# Review + +- tests pass +`, 'acme/billing/refunds/SKILL.md': '---\r\nname: refunds\r\ndescription: Refund workflow.\r\n---\r\n# Refunds\r\n', 'acme/billing/refunds/templates/Purchase Order.md': '# PO\n', 'acme/billing/refunds/assets/logo.png': PNG, - 'acme/billing/refunds/escalation/SKILL.md': '---\nname: escalation\ndescription: Escalate a refund.\n---\n# Escalation\n' + 'acme/billing/refunds/escalation/SKILL.md': `--- +name: escalation +description: Escalate a refund. +--- +# Escalation +` }; export const GIT = 'skill://git-workflow/SKILL.md'; diff --git a/test/integration/test/skills/skillsEndToEnd.test.ts b/test/integration/test/skills/skillsEndToEnd.test.ts index edbc6c6c04..e7970b02fe 100644 --- a/test/integration/test/skills/skillsEndToEnd.test.ts +++ b/test/integration/test/skills/skillsEndToEnd.test.ts @@ -180,7 +180,14 @@ describe('one catalog behind per-request HTTP servers', () => { expect(subscription.honoredFilter).toEqual({ resourcesListChanged: true }); await mkdir(path.join(editable, 'aaa')); - await writeFile(path.join(editable, 'aaa', 'SKILL.md'), '---\nname: aaa\ndescription: Sorts first.\n---\n'); + await writeFile( + path.join(editable, 'aaa', 'SKILL.md'), + `--- +name: aaa +description: Sorts first. +--- +` + ); const changes = await own.reload(); expect(changes).toMatchObject({ added: ['skill://aaa/SKILL.md'], listChanged: true }); // Every server this handler built has served its one request and closed: nothing reaches the stream. diff --git a/test/integration/test/skills/skillsWire.test.ts b/test/integration/test/skills/skillsWire.test.ts index 9ee83e3780..83aa40bc21 100644 --- a/test/integration/test/skills/skillsWire.test.ts +++ b/test/integration/test/skills/skillsWire.test.ts @@ -17,9 +17,19 @@ const EXTENSION = 'io.modelcontextprotocol/skills'; const SERVER_INFO = 'io.modelcontextprotocol/serverInfo'; const Loose = z.looseObject({}); -const GIT_SKILL_MD = '---\nname: git-workflow\ndescription: Branching and review conventions.\n---\n# Git workflow\n'; +const GIT_SKILL_MD = `--- +name: git-workflow +description: Branching and review conventions. +--- +# Git workflow +`; const REVIEW_MD = '# Review\n'; -const NOTES_MD = '---\nname: notes\ndescription: Team notes.\n---\n# Notes\n'; +const NOTES_MD = `--- +name: notes +description: Team notes. +--- +# Notes +`; const GIT = 'skill://git-workflow/SKILL.md'; const REVIEW = 'skill://git-workflow/references/REVIEW.md'; From 290d0497ae7e69a287fa720616b50973b659d387 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 17:46:38 +0300 Subject: [PATCH 38/45] fix(core-internal): look skill MIME types up by own keys only mimeTypeFor read the extension off a plain object literal, so a file named notes.__proto__ was published with mimeType {} and x.constructor with a function, and clients rejected the read. Inherited names now fall back to application/octet-stream. --- packages/core-internal/src/ext/skills/mime.ts | 4 +++- .../core-internal/test/ext/skills/mime.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 packages/core-internal/test/ext/skills/mime.test.ts diff --git a/packages/core-internal/src/ext/skills/mime.ts b/packages/core-internal/src/ext/skills/mime.ts index 34cf076028..36f17c19e7 100644 --- a/packages/core-internal/src/ext/skills/mime.ts +++ b/packages/core-internal/src/ext/skills/mime.ts @@ -28,5 +28,7 @@ const BY_EXTENSION: Readonly> = { export function mimeTypeFor(fileName: string): string { const dot = fileName.lastIndexOf('.'); if (dot <= 0) return 'application/octet-stream'; - return BY_EXTENSION[fileName.slice(dot + 1).toLowerCase()] ?? 'application/octet-stream'; + const extension = fileName.slice(dot + 1).toLowerCase(); + // Own keys only: an inherited name such as `__proto__` or `constructor` is not a MIME type. + return Object.hasOwn(BY_EXTENSION, extension) ? BY_EXTENSION[extension]! : 'application/octet-stream'; } diff --git a/packages/core-internal/test/ext/skills/mime.test.ts b/packages/core-internal/test/ext/skills/mime.test.ts new file mode 100644 index 0000000000..c10bd12e91 --- /dev/null +++ b/packages/core-internal/test/ext/skills/mime.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { mimeTypeFor } from '../../../src/ext/skills/mime'; + +describe('mimeTypeFor', () => { + it('maps known extensions, case-insensitively', () => { + expect(mimeTypeFor('SKILL.md')).toBe('text/markdown'); + expect(mimeTypeFor('chart.PNG')).toBe('image/png'); + }); + + it.each(['notes', '.env', 'archive.unknown', 'notes.__proto__', 'x.constructor', 'x.hasOwnProperty', 'x.valueOf'])( + 'falls back to application/octet-stream for %s', + name => { + expect(mimeTypeFor(name)).toBe('application/octet-stream'); + } + ); +}); From 752105801f4dbfff1c7160d82d4ba7bbf1ec400f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 17:47:36 +0300 Subject: [PATCH 39/45] fix(server): keep a throwing onWarning from escaping list_changed notification When a list_changed send failed and onWarning threw, the throw escaped: on a synchronous send failure it rejected reload() and skipped the remaining servers, and on an asynchronous one it became an unhandled rejection. The warning call is now guarded, as the comment on #notify already promised. --- packages/server/src/ext/skills/extension.ts | 6 +++- .../server/test/ext/skills/catalog.test.ts | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/server/src/ext/skills/extension.ts b/packages/server/src/ext/skills/extension.ts index b15cbbc845..123607f045 100644 --- a/packages/server/src/ext/skills/extension.ts +++ b/packages/server/src/ext/skills/extension.ts @@ -373,6 +373,10 @@ export class SkillCatalog extends SkillsExtension { #notifyFailed(sessionId: string | undefined, error: unknown): void { const to = sessionId === undefined ? '' : ` to session ${sessionId}`; const reason = error instanceof Error ? error.message : String(error); - this.#build.warn({ path: 'resources/list_changed', message: `could not be sent${to}: ${reason}` }); + try { + this.#build.warn({ path: 'resources/list_changed', message: `could not be sent${to}: ${reason}` }); + } catch { + // A throwing onWarning must not escape either: it would reject reload() or go unhandled. + } } } diff --git a/packages/server/test/ext/skills/catalog.test.ts b/packages/server/test/ext/skills/catalog.test.ts index 9595df52f3..6f3894ffa2 100644 --- a/packages/server/test/ext/skills/catalog.test.ts +++ b/packages/server/test/ext/skills/catalog.test.ts @@ -324,4 +324,37 @@ description: d await mcp.close(); } }); + + it('keeps a throwing onWarning from escaping a failed send or skipping the other servers', async () => { + const sink = vi.fn(() => { + throw new Error('sink failed'); + }); + const { catalog, files } = await loaded({ onWarning: sink }); + const servers = [newServer(), newServer(), newServer()]; + for (const server of servers) { + catalog.attach(server); + await server.connect(InMemoryTransport.createLinkedPair()[1]); + } + const [throwsSync, rejects, healthy] = servers.map(server => vi.spyOn(server.server, 'sendResourceListChanged')); + throwsSync!.mockImplementation(() => { + throw new Error('send threw'); + }); + rejects!.mockRejectedValue(new Error('transport down')); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + files['aaa/SKILL.md'] = NEW_SKILL; + await expect(catalog.reload()).resolves.toMatchObject({ listChanged: true }); + expect(healthy).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(sink).toHaveBeenCalledTimes(2)); + await new Promise(resolve => setImmediate(resolve)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + for (const server of servers) await server.close(); + } + }); }); From 8127bc53b071d2e7c396139011600782d17b1a6f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 17:49:40 +0300 Subject: [PATCH 40/45] fix(client): request a dynamic skill file at the URI its ref resolves to For a dynamic skill, read() requested the normalised key rather than the resolved ref, turning Q%26A.md into Q&A.md. RFC 3986 treats a reserved character and its percent-encoding as different URIs, so a server that keys files by exact URI answered not-found. read() now requests the resolved URI (fragment dropped) and matches the returned contents by key, so a reply that spells the file differently is still accepted. Listed files keep requesting the entry's spelling. --- .../client/src/ext/skills/skillsClient.ts | 24 ++++++++++------- packages/client/test/ext/skills/read.test.ts | 26 +++++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index 2371528f44..e9068d998f 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -76,17 +76,22 @@ function decodeBlob(uri: string, blob: string): Uint8Array { } } -/** What `read` requests and checks: the entry's spelling of the file, its listing, and whether it is the skill's own `SKILL.md`. */ +/** What `read` requests and checks: the file's URI and key, its listing, and whether it is the skill's own `SKILL.md`. */ interface ReadTarget { + /** The entry's spelling of a listed file; for a `"dynamic"` skill, the URI the ref resolves to. */ uri: string; + root: string; + /** The file's {@linkcode skillFileKey}: listings and returned contents are matched by it. */ + key: string; /** `undefined` for `"dynamic"` skills. */ listed: SkillResourceEntry | undefined; manifest: boolean; } /** - * Resolves `ref` against the skill root, drops its fragment, and finds the file in the entry by - * {@linkcode skillFileKey}, since servers percent-encode names differently (`Q&A.md`, `Q%26A.md`). + * Resolves `ref` against the skill root and drops its fragment. A listed file is found in the entry by + * {@linkcode skillFileKey}, since servers percent-encode names differently (`Q&A.md`, `Q%26A.md`); a + * `"dynamic"` skill's file is requested at the resolved URI, as nothing published another spelling. * Throws `unlisted` for a ref that does not parse or names no file of the skill. */ function resolveTarget(skill: Skill, ref: string | undefined): ReadTarget { @@ -94,7 +99,9 @@ function resolveTarget(skill: Skill, ref: string | undefined): ReadTarget { if (root === undefined) throw new TypeError(`${skill.uri} is not a SKILL.md URI`); let uri: string; try { - uri = resolveSkillRef(skill.uri, ref); + const resolved = new URL(resolveSkillRef(skill.uri, ref)); + resolved.hash = ''; + uri = resolved.href; } catch (error) { const given = ref ?? ''; throw new SkillVerificationError('unlisted', given, `${given} is not a URI reference`, [], { cause: error }); @@ -104,11 +111,11 @@ function resolveTarget(skill: Skill, ref: string | undefined): ReadTarget { const notInSkill = () => new SkillVerificationError('unlisted', uri, `${uri} is not a file of the skill ${skill.uri}`); if (skill.resources === 'dynamic') { if (!manifest && !isWithin(root, key)) throw notInSkill(); - return { uri: key, listed: undefined, manifest }; + return { uri, root, key, listed: undefined, manifest }; } const listed = skill.resources.find(resource => skillFileKey(root, resource.uri) === key); if (listed === undefined) throw notInSkill(); - return { uri: listed.uri, listed, manifest }; + return { uri: listed.uri, root, key, listed, manifest }; } function unsupported(method: string): SdkError { @@ -227,9 +234,8 @@ export class SkillsClient { } async #verify(skill: Skill, target: ReadTarget, result: ReadResourceResult): Promise { - const { uri, listed } = target; - const requested = canonicalUri(uri) ?? uri; - const content = result.contents.find(item => (canonicalUri(item.uri) ?? item.uri) === requested); + const { uri, root, key, listed } = target; + const content = result.contents.find(item => (skillFileKey(root, item.uri) ?? item.uri) === key); if (content === undefined) throw new SdkError(SdkErrorCode.InvalidResult, `resources/read returned no contents for ${uri}`); const bytes = 'text' in content ? encodeUtf8(content.text) : decodeBlob(uri, content.blob); if (listed !== undefined) { diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts index 5f73d5542e..f88b2168f9 100644 --- a/packages/client/test/ext/skills/read.test.ts +++ b/packages/client/test/ext/skills/read.test.ts @@ -99,6 +99,32 @@ describe('SkillsClient.read', () => { expect(reads(calls)).toEqual([overEncoded, overEncoded]); }); + it('requests a dynamic skill file at the URI its ref resolves to', async () => { + const overEncoded = `${PDF_ROOT}/references/Q%26A.md`; + const { skill, calls, skills } = await publishedAs( + new Map([ + [MANIFEST, PDF_SKILL_MD], + [overEncoded, '# Q&A\n'] + ]) + ); + const dynamic: Skill = { ...skill, resources: 'dynamic' }; + expect(await skills.read(dynamic, 'references/Q%26A.md')).toMatchObject({ uri: overEncoded, text: '# Q&A\n', verified: false }); + expect(reads(calls)).toEqual([overEncoded]); + }); + + it('accepts contents that spell the requested file differently', async () => { + const skill = await entryFor(PDF_ROOT, { ...PDF_FILES, 'references/Q&A.md': '# Q&A\n' }); + const echoed = `${PDF_ROOT}/references/Q%26A.md`; + const { client } = await scriptedClient(CAPABILITIES, { + ...skillsHandlers([skill], new Map()), + 'resources/read': () => ({ contents: [{ uri: echoed, text: '# Q&A\n' }], ttlMs: 0, cacheScope: 'private' }) + }); + const skills = new SkillsClient(client); + for (const target of [skill, { ...skill, resources: 'dynamic' } satisfies Skill]) { + expect(await skills.read(target, 'references/Q&A.md')).toMatchObject({ text: '# Q&A\n' }); + } + }); + it('refuses an invalid entry before any request: a file listed in two spellings, or outside the skill', async () => { const { skills, skill, calls } = await connected({ files: { ...PDF_FILES, 'references/Q&A.md': '# Q&A\n' } }); const listed = skill.resources as SkillResourceEntry[]; From ba97a2afbcc1c2854957019e12f9d54a0717b0ac Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 17:52:24 +0300 Subject: [PATCH 41/45] fix(client): make SkillsClient.list() strict by default, with skipInvalid to keep valid entries list() returned the valid entries and moved invalid ones into invalid unless the caller opted into strict, the only list verb in the SDK that tolerated a spec-breaking peer by default. It now throws an SdkError naming every invalid entry, as listTools and listResources reject a malformed result and as the Go and C# skills clients do; skipInvalid: true keeps the valid entries and returns the rest in invalid. The strict option is replaced by skipInvalid. --- .changeset/skills-extension.md | 2 +- docs/clients/skills.md | 9 +++++---- examples/guides/clients/skills.examples.ts | 3 ++- .../client/src/ext/skills/skillsClient.ts | 15 ++++++++------- .../test/ext/skills/skillsClient.test.ts | 19 ++++++++++++------- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.changeset/skills-extension.md b/.changeset/skills-extension.md index 4afefeae4a..01bede1d7e 100644 --- a/.changeset/skills-extension.md +++ b/.changeset/skills-extension.md @@ -12,7 +12,7 @@ Add the Skills extension (SEP-2640), reachable through new `ext/skills` subpaths - `@modelcontextprotocol/server/ext/skills`: `SkillsExtension` (`attach(server)` serves `skills/list`, `skills/get`, `resources/directory/read` and a `skill://{+path}` resource template), `SkillsExtension.load(files)` returning a `SkillCatalog` with `entries` and `reload()`, the `SkillFiles` storage adapter with `memorySkillFiles`, `SkillSource` for generated catalogs, `skillEntry`, and `SkillCatalogError`. Invalid skills are skipped with a warning (`onWarning`), or fail under `strict`. A file whose bytes are valid UTF-8 is served as `text`, whatever its MIME type; any other file as a base64 `blob`. - `@modelcontextprotocol/server/ext/skills/node`: `loadSkills(folder)` and `nodeSkillFiles(folder)`. -- `@modelcontextprotocol/client/ext/skills`: `SkillsClient` with `list()`, `get()`, `read(skill, path?)` (size, digest and `SKILL.md` frontmatter verified, `SkillVerificationError` on a mismatch; a copy from the response cache that fails a check is fetched once more with `cacheMode: 'refresh'` first) and `readDirectory()`. +- `@modelcontextprotocol/client/ext/skills`: `SkillsClient` with `list()` (throws on an invalid entry; `skipInvalid: true` keeps the valid ones and returns the rest in `invalid`), `get()`, `read(skill, path?)` (size, digest and `SKILL.md` frontmatter verified, `SkillVerificationError` on a mismatch; a copy from the response cache that fails a check is fetched once more with `cacheMode: 'refresh'` first) and `readDirectory()`. - `@modelcontextprotocol/core/ext/skills`: schemas, types and constants, including `SKILL_LIMITS`. Works on 2025-11-25 and 2026-07-28; caching fields (`ttlMs`, `cacheScope`) are sent on 2026-07-28 only. diff --git a/docs/clients/skills.md b/docs/clients/skills.md index 6545d9a7e0..6309ca3980 100644 --- a/docs/clients/skills.md +++ b/docs/clients/skills.md @@ -25,14 +25,15 @@ Listing reads no skill file: each **entry** carries the skill's frontmatter and ## Handle refused entries -Log the entries `list()` refused. +Create the client with `skipInvalid: true` to keep the valid skills and log the refused entries. ```ts source="../../examples/guides/clients/skills.examples.ts#list_invalid" -const { invalid } = await skills.list(); +const tolerant = new SkillsClient(client, { skipInvalid: true }); +const { invalid } = await tolerant.list(); for (const refused of invalid) console.error(`skills: refused entry #${refused.index} ${refused.uri ?? ''}: ${refused.reason}`); ``` -`list()` also returns `invalid`: the entries that fail validation, and every copy of a repeated URI, each with `index`, `uri` and `reason`. Against this server it is empty, so the loop prints nothing. Pass `{ strict: true }` to `new SkillsClient` to make `list()` throw an `SdkError` instead. +By default `list()` throws an `SdkError` naming every entry that fails validation, and every copy of a repeated URI. With `skipInvalid: true` it returns the valid skills and puts those entries in `invalid`, each with `index`, `uri` when it has one, and `reason`. Against this server it is empty, so the loop prints nothing. ## Load a skill @@ -142,7 +143,7 @@ Accept skills up to `SKILL_LIMITS` (512 files, 16 MiB) and explain why you decli ## Recap - `new SkillsClient(client)` wraps a connected client; `list()` returns entries without reading files. -- `list()` returns refused entries in `invalid`; `strict: true` makes it throw instead. +- `list()` throws on an invalid entry; `skipInvalid: true` keeps the valid skills and returns the refused ones in `invalid`. - `read(entry)` verifies `SKILL.md` (size, digest, frontmatter) and returns its frontmatter and body. - `read(entry, path)` resolves a path against the skill's folder and verifies that file. - A failed check throws `SkillVerificationError` with a `reason`. diff --git a/examples/guides/clients/skills.examples.ts b/examples/guides/clients/skills.examples.ts index d254eda977..50f55223ef 100644 --- a/examples/guides/clients/skills.examples.ts +++ b/examples/guides/clients/skills.examples.ts @@ -53,7 +53,8 @@ console.log(entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter //#endregion SkillsClient_list //#region list_invalid -const { invalid } = await skills.list(); +const tolerant = new SkillsClient(client, { skipInvalid: true }); +const { invalid } = await tolerant.list(); for (const refused of invalid) console.error(`skills: refused entry #${refused.index} ${refused.uri ?? ''}: ${refused.reason}`); //#endregion list_invalid diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index e9068d998f..70b74dcd3d 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -35,11 +35,11 @@ import { frontmatterDifferences } from './verify'; export interface SkillsClientOptions { /** Parses a `SKILL.md`'s frontmatter for verification instead of the built-in YAML parser. */ parseFrontmatter?: (skillMd: string) => Record; - /** Throw when `list()` meets an invalid entry instead of returning it in `invalid`. Default `false`. */ - strict?: boolean; + /** Return invalid `skills/list` entries in `invalid` instead of throwing. Default `false`: `list()` throws on any. */ + skipInvalid?: boolean; } -/** What `list()` returns: the valid entries, the refused ones, and the first page's caching fields. */ +/** What `list()` returns: the valid entries, the refused ones (only with `skipInvalid`), and the first page's caching fields. */ export interface SkillListing { skills: Skill[]; invalid: InvalidSkillEntry[]; @@ -160,9 +160,10 @@ export class SkillsClient { /** * Lists the server's skills, walking every page unless given a cursor - * (bounded by the client's `listMaxPages`). Invalid entries, and every - * copy of a repeated URI, are returned in `invalid` (or thrown under - * `strict`). On a server without the extension: an empty listing. + * (bounded by the client's `listMaxPages`). Throws an `SdkError` naming + * every invalid entry, and every copy of a repeated URI; with + * `skipInvalid`, returns them in `invalid` instead. On a server without + * the extension: an empty listing. */ async list(params: { cursor?: string } = {}, options?: RequestOptions): Promise { if (!this.supported) { @@ -173,7 +174,7 @@ export class SkillsClient { } const page = await this.#pages(SKILLS_LIST_METHOD, SkillsPageSchema, {}, params.cursor, options, result => result.skills); const { skills, invalid } = partitionEntries(page.skills); - if (invalid.length > 0 && this.#options.strict) { + if (invalid.length > 0 && !this.#options.skipInvalid) { const detail = invalid.map(entry => `#${entry.index} ${entry.uri ?? ''}: ${entry.reason}`).join('\n'); throw new SdkError(SdkErrorCode.InvalidResult, `skills/list returned invalid entries:\n${detail}`, { invalid }); } diff --git a/packages/client/test/ext/skills/skillsClient.test.ts b/packages/client/test/ext/skills/skillsClient.test.ts index 458cbda3f5..5cd7bb5dd1 100644 --- a/packages/client/test/ext/skills/skillsClient.test.ts +++ b/packages/client/test/ext/skills/skillsClient.test.ts @@ -59,7 +59,7 @@ describe('SkillsClient.list', () => { expect(await new SkillsClient(client).list({ cursor: '1' })).toMatchObject({ skills: [entries[1]], nextCursor: '2' }); }); - it('moves invalid entries, and every copy of a repeated URI, into invalid', async () => { + it('with skipInvalid, moves invalid entries, and every copy of a repeated URI, into invalid', async () => { const [alpha, beta, gamma] = await threeSkills(); const misnamed = { ...beta!, frontmatter: { ...beta!.frontmatter, name: 'other' } }; const escaping = { @@ -68,7 +68,7 @@ describe('SkillsClient.list', () => { }; const handlers: Record = { 'skills/list': () => ({ skills: [alpha, misnamed, escaping, { uri: 42 }, alpha] }) }; const { client } = await scriptedClient(CAPABILITIES, handlers); - const { skills, invalid } = await new SkillsClient(client).list(); + const { skills, invalid } = await new SkillsClient(client, { skipInvalid: true }).list(); expect(skills).toEqual([]); expect(invalid).toEqual([ { index: 0, uri: alpha!.uri, reason: expect.stringMatching(/more than once/) }, @@ -79,10 +79,15 @@ describe('SkillsClient.list', () => { ]); }); - it('throws on any invalid entry when strict', async () => { - const [alpha] = await threeSkills(); - const { client } = await scriptedClient(CAPABILITIES, { 'skills/list': () => ({ skills: [alpha, alpha] }) }); - await expect(new SkillsClient(client, { strict: true }).list()).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult }); + it('throws on any invalid entry by default, naming every one', async () => { + const [alpha, beta] = await threeSkills(); + const { client } = await scriptedClient(CAPABILITIES, { 'skills/list': () => ({ skills: [alpha, alpha, beta] }) }); + await expect(new SkillsClient(client).list()).rejects.toMatchObject({ + code: SdkErrorCode.InvalidResult, + data: { + invalid: [expect.objectContaining({ index: 0, uri: alpha!.uri }), expect.objectContaining({ index: 1, uri: alpha!.uri })] + } + }); }); it('throws on a malformed response', async () => { @@ -113,7 +118,7 @@ describe('SkillsClient.list', () => { 'skills/list': () => ({ skills: [wire] }), 'skills/get': () => ({ skill: wire }) }); - const skills = new SkillsClient(client); + const skills = new SkillsClient(client, { skipInvalid: true }); expect(await skills.list()).toEqual({ skills: [], invalid: [{ index: 0, uri: alpha!.uri, reason: expect.stringContaining('__proto__') }] From 8117e28134a95963a88859e544257af982901d15 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 17:53:09 +0300 Subject: [PATCH 42/45] docs(skills): name the YAML typing that frontmatter verification uses The client compares an entry's frontmatter with its own YAML 1.2 core-schema parse, as SEP-2640 requires field by field, so a server whose YAML parser types values differently (YAML 1.1 reads yes as true) fails verification. The client guide and the parseFrontmatter JSDoc now say so and point at parseFrontmatter. --- docs/clients/skills.md | 2 ++ packages/client/src/ext/skills/skillsClient.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/clients/skills.md b/docs/clients/skills.md index 6309ca3980..a586fbdb9f 100644 --- a/docs/clients/skills.md +++ b/docs/clients/skills.md @@ -56,6 +56,8 @@ true { `read(entry)` checks the size and SHA-256 digest against the entry, then compares the frontmatter field by field. `manifest.body` holds the instructions to give the model. +The comparison parses the frontmatter with YAML 1.2 core-schema typing, so `yes` is the string `"yes"`, not `true`. If a server builds its entries with a parser that types values differently, pass a matching parser as `parseFrontmatter` to `new SkillsClient`. + ## Read the files a skill references Pass the path `SKILL.md` uses. diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index 70b74dcd3d..b46ad0330e 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -33,7 +33,7 @@ import { GetSkillEnvelopeSchema, SkillsPageSchema } from './pageSchemas'; import { frontmatterDifferences } from './verify'; export interface SkillsClientOptions { - /** Parses a `SKILL.md`'s frontmatter for verification instead of the built-in YAML parser. */ + /** Parses a `SKILL.md`'s frontmatter for verification instead of the built-in parser (YAML 1.2 core-schema typing). */ parseFrontmatter?: (skillMd: string) => Record; /** Return invalid `skills/list` entries in `invalid` instead of throwing. Default `false`: `list()` throws on any. */ skipInvalid?: boolean; From c033b092705e39c211ad48b33fda490f756e8745 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 17:57:21 +0300 Subject: [PATCH 43/45] refactor(core): drop three unused skills constants SKILL_URI_SCHEME, SKILL_MANIFEST_FILENAME and SKILLS_META_PREFIX were exported from three ext/skills subpaths, yet nothing in the SDK read them; the code spells the values inline, and SKILL_URI_SCHEME ('skill:') did not even match the 'skill://' the code uses. The extension has not shipped, so they go before they become API. --- packages/client/src/ext/skills/index.ts | 5 +---- packages/core/src/ext/skills/constants.ts | 9 --------- packages/core/src/ext/skills/index.ts | 5 +---- packages/server/src/ext/skills/index.ts | 5 +---- 4 files changed, 3 insertions(+), 21 deletions(-) diff --git a/packages/client/src/ext/skills/index.ts b/packages/client/src/ext/skills/index.ts index aef9408d45..515a89c3f0 100644 --- a/packages/client/src/ext/skills/index.ts +++ b/packages/client/src/ext/skills/index.ts @@ -25,10 +25,7 @@ export { DIRECTORY_MIME_TYPE, RESOURCES_DIRECTORY_READ_METHOD, SKILL_LIMITS, - SKILL_MANIFEST_FILENAME, - SKILL_URI_SCHEME, SKILLS_EXTENSION_ID, SKILLS_GET_METHOD, - SKILLS_LIST_METHOD, - SKILLS_META_PREFIX + SKILLS_LIST_METHOD } from '@modelcontextprotocol/core/ext/skills'; diff --git a/packages/core/src/ext/skills/constants.ts b/packages/core/src/ext/skills/constants.ts index dd5ca183a5..9ee95270fc 100644 --- a/packages/core/src/ext/skills/constants.ts +++ b/packages/core/src/ext/skills/constants.ts @@ -16,18 +16,9 @@ export const SKILLS_GET_METHOD = 'skills/get'; /** Lists the direct children of a directory resource (gated by `directoryRead`). */ export const RESOURCES_DIRECTORY_READ_METHOD = 'resources/directory/read'; -/** The conventional URI scheme for skills. Servers MAY use other schemes. */ -export const SKILL_URI_SCHEME = 'skill:'; - -/** The manifest file that every skill entry's `uri` points at. */ -export const SKILL_MANIFEST_FILENAME = 'SKILL.md'; - /** The `mimeType` of a directory resource. */ export const DIRECTORY_MIME_TYPE = 'inode/directory'; -/** Reverse-domain prefix for `_meta` keys this extension defines. */ -export const SKILLS_META_PREFIX = 'io.modelcontextprotocol.skills/'; - /** * Per-skill interoperability limits. Hosts MUST accept skills up to these; * servers SHOULD NOT publish skills over them. diff --git a/packages/core/src/ext/skills/index.ts b/packages/core/src/ext/skills/index.ts index af480cd838..0544030d45 100644 --- a/packages/core/src/ext/skills/index.ts +++ b/packages/core/src/ext/skills/index.ts @@ -12,12 +12,9 @@ export { RESOURCES_DIRECTORY_READ_METHOD, SKILL_DIGEST_PATTERN, SKILL_LIMITS, - SKILL_MANIFEST_FILENAME, - SKILL_URI_SCHEME, SKILLS_EXTENSION_ID, SKILLS_GET_METHOD, - SKILLS_LIST_METHOD, - SKILLS_META_PREFIX + SKILLS_LIST_METHOD } from './constants'; export { DirectoryResourceSchema, diff --git a/packages/server/src/ext/skills/index.ts b/packages/server/src/ext/skills/index.ts index 14e5891e9a..e48b9fc347 100644 --- a/packages/server/src/ext/skills/index.ts +++ b/packages/server/src/ext/skills/index.ts @@ -35,10 +35,7 @@ export { DIRECTORY_MIME_TYPE, RESOURCES_DIRECTORY_READ_METHOD, SKILL_LIMITS, - SKILL_MANIFEST_FILENAME, - SKILL_URI_SCHEME, SKILLS_EXTENSION_ID, SKILLS_GET_METHOD, - SKILLS_LIST_METHOD, - SKILLS_META_PREFIX + SKILLS_LIST_METHOD } from '@modelcontextprotocol/core/ext/skills'; From ba0dcb5c6bd158d635d13d2b4156f792c942edfd Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 17:57:37 +0300 Subject: [PATCH 44/45] chore(core-internal): cut the skills note on the barrel to one line The repository keeps inline comments to one line; the reasoning (skills on the root barrel put the frontmatter parser in every package's root module graph) is in 7ee8d919's message. --- packages/core-internal/src/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 22658d9537..c93a604fa1 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -1,11 +1,7 @@ export * from './auth/errors'; export * from './errors/crossBundleBrand'; export * from './errors/sdkErrors'; -// The Skills extension's internals (SEP-2640) are deliberately NOT on this barrel: -// they live on the `./ext/skills` subpath, so only the client and server -// `ext/skills` entries load the frontmatter parser and the -// `@modelcontextprotocol/core/ext/skills` import (on this barrel they landed in -// every package's root module graph). +// Skills internals live on the `./ext/skills` subpath, not here, so package roots never load them. export * from './shared/auth'; export * from './shared/authUtils'; export * from './shared/clientCapabilityRequirements'; From a7da98d4300c3ccae41add980d1cdeb68cead907 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 18 Oct 2026 18:10:18 +0300 Subject: [PATCH 45/45] fix(client): pass parseFrontmatter only the YAML between the --- lines parseFrontmatter received the whole SKILL.md, so a host following the guide and passing a YAML parser got a multi-document error, and every read() failed frontmatter verification; the client exposes no splitter to work around it. The client now splits the file itself (BOM and CRLF removed) and passes the YAML block, so parseFrontmatter: yaml => YAML.parse(yaml) works as the guide describes. --- docs/clients/skills.md | 2 +- packages/client/src/ext/skills/skillsClient.ts | 13 ++++++++----- packages/client/test/ext/skills/read.test.ts | 17 +++++++++++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/clients/skills.md b/docs/clients/skills.md index a586fbdb9f..bf6b87352b 100644 --- a/docs/clients/skills.md +++ b/docs/clients/skills.md @@ -56,7 +56,7 @@ true { `read(entry)` checks the size and SHA-256 digest against the entry, then compares the frontmatter field by field. `manifest.body` holds the instructions to give the model. -The comparison parses the frontmatter with YAML 1.2 core-schema typing, so `yes` is the string `"yes"`, not `true`. If a server builds its entries with a parser that types values differently, pass a matching parser as `parseFrontmatter` to `new SkillsClient`. +The comparison parses the frontmatter with YAML 1.2 core-schema typing, so `yes` is the string `"yes"`, not `true`. If a server builds its entries with a parser that types values differently, pass a matching YAML parser as `parseFrontmatter` to `new SkillsClient`; it receives the YAML between the `---` lines. ## Read the files a skill references diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index b46ad0330e..b30f35cd70 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -33,8 +33,11 @@ import { GetSkillEnvelopeSchema, SkillsPageSchema } from './pageSchemas'; import { frontmatterDifferences } from './verify'; export interface SkillsClientOptions { - /** Parses a `SKILL.md`'s frontmatter for verification instead of the built-in parser (YAML 1.2 core-schema typing). */ - parseFrontmatter?: (skillMd: string) => Record; + /** + * Parses the YAML between a `SKILL.md`'s `---` lines (BOM and CRLF removed) for verification, instead of the + * built-in parser (YAML 1.2 core-schema typing). + */ + parseFrontmatter?: (yaml: string) => Record; /** Return invalid `skills/list` entries in `invalid` instead of throwing. Default `false`: `list()` throws on any. */ skipInvalid?: boolean; } @@ -274,9 +277,9 @@ export class SkillsClient { #parse(uri: string, markdown: string): { frontmatter: Record; body: string } { try { const parse = this.#options.parseFrontmatter; - return parse === undefined - ? parseSkillMarkdown(markdown) - : { frontmatter: parse(markdown), body: splitFrontmatter(markdown).body }; + if (parse === undefined) return parseSkillMarkdown(markdown); + const { yaml, body } = splitFrontmatter(markdown); + return { frontmatter: parse(yaml), body }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new SkillVerificationError('frontmatter', uri, `the frontmatter of ${uri} cannot be read: ${reason}`, [], { diff --git a/packages/client/test/ext/skills/read.test.ts b/packages/client/test/ext/skills/read.test.ts index f88b2168f9..9adb71355e 100644 --- a/packages/client/test/ext/skills/read.test.ts +++ b/packages/client/test/ext/skills/read.test.ts @@ -252,7 +252,7 @@ describe('SkillsClient.read', () => { await expect(new SkillsClient(client).read(skill)).rejects.toMatchObject({ reason: 'frontmatter' }); }); - it('uses parseFrontmatter when given', async () => { + it('passes parseFrontmatter only the YAML between the --- lines', async () => { const parseFrontmatter = vi.fn(() => ({ name: 'pdf-processing', description: 'Extract text and fill forms in PDF files.', @@ -260,7 +260,20 @@ describe('SkillsClient.read', () => { })); const { skills, skill } = await connected({ options: { parseFrontmatter } }); expect(await skills.read(skill)).toMatchObject({ verified: true, body: expect.stringContaining('# PDF processing') }); - expect(parseFrontmatter).toHaveBeenCalledWith(PDF_SKILL_MD); + expect(parseFrontmatter).toHaveBeenCalledWith(`name: pdf-processing +description: Extract text and fill forms in PDF files. +metadata: + author: acme + version: "1.0" +`); + }); + + it('passes parseFrontmatter the YAML of a Windows-authored SKILL.md with the BOM and CRLF removed', async () => { + const windows = { 'SKILL.md': '---\r\nname: pdf-processing\r\ndescription: Extract text.\r\n---\r\n# PDF\r\n' }; + const parseFrontmatter = vi.fn(() => ({ name: 'pdf-processing', description: 'Extract text.' })); + const { skills, skill } = await connected({ files: windows, options: { parseFrontmatter } }); + expect(await skills.read(skill)).toMatchObject({ verified: true }); + expect(parseFrontmatter).toHaveBeenCalledWith('name: pdf-processing\ndescription: Extract text.\n'); }); it('verifies a Windows-authored SKILL.md (BOM and CRLF)', async () => {