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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/ext-skills-phase-1.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
],
Expand Down
105 changes: 105 additions & 0 deletions packages/client/src/ext/skills/index.ts
Original file line number Diff line number Diff line change
@@ -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<ListSkillsResult> {
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<GetSkillResult> {
assertSkillsSupported(client, SKILLS_GET_METHOD);
return client.request({ method: SKILLS_GET_METHOD, params }, GetSkillResultSchema, options);
}
151 changes: 151 additions & 0 deletions packages/client/test/ext/skills/skills.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown> | undefined> }> {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const listParams: Array<Record<string, unknown> | undefined> = [];

serverTx.onmessage = m => {
const r = m as JSONRPCRequest;
if (r.id === undefined) return;
const send = (result: Record<string, unknown>) =>
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<string, unknown> | 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/);
});
});
1 change: 1 addition & 0 deletions packages/client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
6 changes: 4 additions & 2 deletions packages/client/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'],
Expand All @@ -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']
});
12 changes: 9 additions & 3 deletions packages/core-internal/test/packageTopologyPins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@ function readManifest(relativeDir: string): PackageManifest {
const PUBLIC_PACKAGES: Record<string, { name: string; exportKeys: string[]; bin?: Record<string, string> }> = {
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',
Expand All @@ -56,7 +59,10 @@ const PUBLIC_PACKAGES: Record<string, { name: string; exportKeys: string[]; bin?
// './internal' is the wholesale internal seam the sibling SDK packages resolve at
// runtime (their bundles keep `@modelcontextprotocol/core/internal` imports external);
// it is not public API — the curated public surface stays the root entry.
exportKeys: ['.', './internal']
// './ext/skills' is the shared (schemas + types + constants) half of the Skills
// extension (SEP-2640): public API, runtime-neutral, and deliberately NOT on the root
// entry, which stays the curated spec + OAuth `*Schema` surface.
exportKeys: ['.', './internal', './ext/skills']
},
codemod: {
name: '@modelcontextprotocol/codemod',
Expand Down
13 changes: 13 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
},
Expand Down
Loading
Loading