Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/skills-as-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': minor
---

`SkillsExtension` is now a server extension: serve it with `new McpServer(info, { extensions: [skills] })`. `SkillsExtension.attach(server)` still works and is deprecated; it will be removed in a later minor release. Passing two skills extensions to one server in `extensions` fails construction (a duplicate extension id). With `extensions`, the skills resource template is registered at construction, so a `ResourceTemplate` the application registers for `skill://` URIs comes after it; a fixed-URI resource still wins at its URI.
3 changes: 1 addition & 2 deletions examples/guides/clients/skills.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ description: Process customer refunds.
'acme/billing/refunds/templates/refund-email.md': 'Your refund is on its way.\n'
})
);
const server = new McpServer({ name: 'team-skills', version: '1.0.0' });
catalog.attach(server);
const server = new McpServer({ name: 'team-skills', version: '1.0.0' }, { extensions: [catalog] });
const client = new Client({ name: 'skills-docs-host', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
Expand Down
16 changes: 6 additions & 10 deletions examples/guides/servers/skills.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,18 @@ 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);
const server = new McpServer({ name: 'team-skills', version: '1.0.0' }, { extensions: [skills] });
//#endregion loadSkills_basic

//#region entries
console.log(skills.entries.map(skill => skill.uri).join('\n'));
//#endregion entries

//#region attach_perRequest
//#region extensions_perRequest
import { createMcpHandler } from '@modelcontextprotocol/server';

const handler = createMcpHandler(() => {
const mcp = new McpServer({ name: 'team-skills', version: '1.0.0' });
skills.attach(mcp);
return mcp;
});
//#endregion attach_perRequest
const handler = createMcpHandler(() => new McpServer({ name: 'team-skills', version: '1.0.0' }, { extensions: [skills] }));
//#endregion extensions_perRequest

// What the handler publishes to `subscriptions/listen` streams, recorded for the checks at the end.
const published: string[] = [];
Expand Down Expand Up @@ -164,6 +159,7 @@ if (!liveChanges.listChanged || JSON.stringify(published) !== JSON.stringify(['r
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' }));
for (const extension of [inline, fromBucket, generated])
new McpServer({ name: 'docs-check', version: '1.0.0' }, { extensions: [extension] });
await handler.close();
await server.close();
8 changes: 3 additions & 5 deletions examples/skills/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* 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.
* under the `examples` prefix: one `loadSkills` call at startup, passed to
* every server instance in `extensions`.
*
* One binary, either transport — selected by `--http --port <N>` (defaults to
* stdio). See `examples/CONTRIBUTING.md` for the canonical shape.
Expand All @@ -17,9 +17,7 @@ import { serveStdio } from '@modelcontextprotocol/server/stdio';
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;
return new McpServer({ name: 'skills-example', version: '1.0.0' }, { extensions: [skills] });
}

const { transport, port } = parseExampleArgs();
Expand Down
97 changes: 57 additions & 40 deletions packages/server/src/ext/skills/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
trimTrailingSlashes
} from '@modelcontextprotocol/core-internal/ext/skills';

import type { ServerExtension, ServerExtensionContext } from '../../server/extension';
import { attachServerExtension } from '../../server/extension';
import type { McpServer } from '../../server/mcp';
import { ResourceTemplate } from '../../server/mcp';
import type { Server } from '../../server/server';
Expand Down Expand Up @@ -58,23 +60,22 @@ function toContents(uri: string, result: SkillReadResult): ReadResourceResult['c
return 'text' in result ? { uri, ...mimeType, text: result.text } : { uri, ...mimeType, blob: encodeBase64(result.bytes) };
}

// Servers with a skills extension attached: one per server, as SEP-3371 rejects a duplicate extension identifier.
const skillsServers = new WeakSet<McpServer>();

// Runs after a successful `attach`, by extension (`SkillCatalog` tracks servers); off the class so no subclass hooks it.
const afterAttach = new WeakMap<SkillsExtension, (mcp: McpServer) => 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)';

// Runs after a successful `setup`, by extension (`SkillCatalog` tracks servers); off the class so no subclass hooks it.
const afterSetup = new WeakMap<SkillsExtension, (mcp: McpServer) => void>();

/**
* 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.
* The MCP Skills extension (SEP-2640) for one catalog of skills, as a server
* extension: `new McpServer(info, { extensions: [skills] })`. One instance
* serves any number of servers. Build it from a {@linkcode SkillSource}, or
* with `SkillsExtension.load(files)` from storage. To serve several folders
* or sources on one server, combine them behind one `SkillFiles` adapter or
* `SkillSource`: a server takes one extension per id.
*/
export class SkillsExtension {
export class SkillsExtension implements ServerExtension {
readonly id = SKILLS_EXTENSION_ID;
readonly #source: SkillSource;
readonly #trusted: boolean;
Expand Down Expand Up @@ -109,47 +110,63 @@ export class SkillsExtension {
}

/**
* Serves these skills on `mcp`: registers the skill resource template and
* the skills methods, then declares the capability. Must run before
* connecting. Calling it again for the same server does nothing. One
* skills extension per server: attaching another one throws and leaves the
* server as it was, as does attaching to a server that already handles one
* of the skills methods.
* Serves these skills on the server being constructed: declares the
* capability, then registers the skill resource template and the skills
* methods. The server calls this for each entry in `extensions`.
*
* @experimental How an extension is wired to a server may change in a minor release.
*/
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);
setup(ctx: ServerExtensionContext): void {
Comment thread
mattzcarey marked this conversation as resolved.
const mcp = ctx.mcp;
const server = mcp.server;
const directoryRead = this.#source.readDirectory !== undefined;
const list = this.#source.listResources === undefined ? undefined : (ctx: ServerContext) => this.#listResources(ctx);
server.assertCanSetRequestHandler(SKILLS_LIST_METHOD);
server.assertCanSetRequestHandler(SKILLS_GET_METHOD);
if (directoryRead) server.assertCanSetRequestHandler(RESOURCES_DIRECTORY_READ_METHOD);
// Check before changing anything; declare the capability last, so a server never advertises skills it does not serve.
if (directoryRead) ctx.capability({ directoryRead: true });

const list = this.#source.listResources === undefined ? undefined : (requestCtx: ServerContext) => this.#listResources(requestCtx);
mcp.registerResource(
SKILLS_EXTENSION_ID,
new ResourceTemplate(`${this.#baseRoot}{+path}`, { list }),
this.#cacheHint === undefined ? {} : { cacheHint: this.#cacheHint },
(uri, _variables, ctx) => this.#read(uri, ctx)
(uri, _variables, requestCtx) => this.#read(uri, requestCtx)
);
server.setRequestHandler(SKILLS_LIST_METHOD, { params: ListSkillsRequestParamsSchema }, (params, ctx) =>
this.#list(server, params.cursor, ctx)
ctx.setRequestHandler(SKILLS_LIST_METHOD, { params: ListSkillsRequestParamsSchema }, (params, requestCtx) =>
this.#list(server, params.cursor, requestCtx)
);
server.setRequestHandler(SKILLS_GET_METHOD, { params: GetSkillRequestParamsSchema }, (params, ctx) =>
this.#get(server, params.uri, ctx)
ctx.setRequestHandler(SKILLS_GET_METHOD, { params: GetSkillRequestParamsSchema }, (params, requestCtx) =>
this.#get(server, params.uri, requestCtx)
);
if (directoryRead) {
server.setRequestHandler(RESOURCES_DIRECTORY_READ_METHOD, { params: ReadResourceDirectoryRequestParamsSchema }, (params, ctx) =>
this.#readDirectory(server, params.uri, params.cursor, ctx)
ctx.setRequestHandler(
RESOURCES_DIRECTORY_READ_METHOD,
{ params: ReadResourceDirectoryRequestParamsSchema },
(params, requestCtx) => this.#readDirectory(server, params.uri, params.cursor, requestCtx)
);
}
server.registerCapabilities({ extensions: { [SKILLS_EXTENSION_ID]: directoryRead ? { directoryRead: true } : {} } });
afterSetup.get(this)?.(mcp);
}

/**
* Serves these skills on `mcp`, which is already constructed. Must run
* before connecting. Calling it again for the same server does nothing.
* One skills extension per server: attaching another one throws and
* leaves the server as it was, as does attaching to a server that
* already handles one of the skills methods.
*
* @deprecated Pass the extension in `extensions` instead:
* `new McpServer(info, { extensions: [skills] })`. `attach` will be
* removed in a later minor release.
*/
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;
if (server.getCapabilities().extensions?.[SKILLS_EXTENSION_ID] !== undefined) throw new Error(ONE_PER_SERVER);
// Check before changing anything, so a refused attach leaves the server as it was.
server.assertCanSetRequestHandler(SKILLS_LIST_METHOD);
server.assertCanSetRequestHandler(SKILLS_GET_METHOD);
if (this.#source.readDirectory !== undefined) server.assertCanSetRequestHandler(RESOURCES_DIRECTORY_READ_METHOD);
attachServerExtension(mcp, this);
this.#attached.add(mcp);
skillsServers.add(mcp);
afterAttach.get(this)?.(mcp);
}

#cacheFields(server: Server): { ttlMs?: number; cacheScope?: 'public' | 'private' } {
Expand Down Expand Up @@ -236,7 +253,7 @@ interface CatalogInit {
readonly sourceOptions: SkillSourceOptions;
}

// Fewest tracked servers at which `attach` drops references to collected ones; then again at twice the survivors.
// Fewest tracked servers at which `setup` drops references to collected ones; then again at twice the survivors.
const PRUNE_THRESHOLD = 1024;

function resolveLimits(limits: SkillCatalogOptions['limits']): { files: number; bytes: number } {
Expand Down Expand Up @@ -280,7 +297,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));
afterSetup.set(this, mcp => this.#track(mcp));
}

static override async load(files: SkillFiles, options: SkillCatalogOptions = {}): Promise<SkillCatalog> {
Expand Down Expand Up @@ -318,12 +335,12 @@ export class SkillCatalog extends SkillsExtension {
*
* 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,
* goes out through every server it is set up on 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.
* before 2025-05-05), servers are not tracked and nothing is sent.
*/
reload(): Promise<SkillCatalogChanges> {
const run = this.#queue.then(() => this.#reloadNow());
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/ext/skills/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function nodeSkillFiles(root: string): SkillFiles {
* @example
* ```ts
* const skills = await loadSkills('./skills');
* skills.attach(mcp);
* const server = new McpServer(info, { extensions: [skills] });
* ```
*/
export function loadSkills(root: string, options?: SkillCatalogOptions): Promise<SkillCatalog> {
Expand Down
31 changes: 31 additions & 0 deletions packages/server/src/server/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,34 @@ function contextFor(mcp: McpServer, id: string, installed: ReadonlySet<string>):
}
};
}

/**
* Sets one extension up on a server that is already constructed, for the
* deprecated `SkillsExtension.attach`. The capability entry is written only
* once `setup` has returned, so a failed setup leaves none. Middleware goes in
* the application tier.
*
* Package-internal.
*/
export function attachServerExtension(mcp: McpServer, extension: ServerExtension): void {
const server = mcp.server;
const installed = new Set([...Object.keys(server.getCapabilities().extensions ?? {}), extension.id]);
let settings: JSONObject = {};
const result = extension.setup({
mcp,
capability(next) {
settings = next;
},
require(extensionId) {
requireExtension('server', installed, extension.id, extensionId);
},
use(method, middleware) {
server.use(method, middleware);
},
setRequestHandler(name, schemas, handler) {
setExtensionRequestHandler(server, extension.id, name, schemas, handler);
}
});
if (result !== undefined) throw new TypeError(`Extension "${extension.id}" has an async setup; pass it in extensions instead`);
server.registerCapabilities({ extensions: { [extension.id]: settings } });
}
28 changes: 11 additions & 17 deletions packages/server/test/ext/skills/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ 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 type { McpServer } from '../../../src/server/mcp';
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);
const mcp = newServer(catalog);
return { catalog, mcp, warnings, files };
}

Expand Down Expand Up @@ -162,22 +162,21 @@ describe('SkillCatalog serving', () => {

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);
const servers = [newServer(catalog), newServer(catalog), newServer(catalog)];
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 () => {
it('keeps setup cheap while many servers it is set up on are still alive', async () => {
const { catalog } = await loaded();
const servers = Array.from({ length: 1100 }, () => newServer());
const servers: McpServer[] = [];
const deref = WeakRef.prototype.deref;
let derefs = 0;
WeakRef.prototype.deref = function (this: WeakRef<WeakKey>) {
derefs++;
return deref.call(this);
};
try {
for (const server of servers) catalog.attach(server);
for (let index = 0; index < 1100; index++) servers.push(newServer(catalog));
} finally {
WeakRef.prototype.deref = deref;
}
Expand Down Expand Up @@ -240,14 +239,13 @@ describe('SkillCatalog.reload', () => {
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 () => {
it('sets up 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();
const mcp = newServer(catalog);
expect(await resultOf(mcp, 'skills/get', { uri: GIT })).toMatchObject({ skill: { uri: GIT } });
const notify = vi.spyOn(mcp.server, 'sendResourceListChanged');
const [, serverSide] = InMemoryTransport.createLinkedPair();
Expand Down Expand Up @@ -283,9 +281,7 @@ description: d

it('notifies each connected server once, and only when the listing changes', async () => {
const { catalog, files, mcp: disconnected } = await loaded();
const mcp = newServer();
catalog.attach(mcp);
catalog.attach(mcp);
const mcp = newServer(catalog);
const notify = vi.spyOn(mcp.server, 'sendResourceListChanged');
const skipped = vi.spyOn(disconnected.server, 'sendResourceListChanged');
const [, serverSide] = InMemoryTransport.createLinkedPair();
Expand All @@ -302,8 +298,7 @@ description: d

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 mcp = newServer(catalog);
const [, serverSide] = InMemoryTransport.createLinkedPair();
await mcp.connect(serverSide);
vi.spyOn(serverSide, 'send').mockRejectedValue(new Error('transport down'));
Expand All @@ -330,9 +325,8 @@ description: d
throw new Error('sink failed');
});
const { catalog, files } = await loaded({ onWarning: sink });
const servers = [newServer(), newServer(), newServer()];
const servers = [newServer(catalog), newServer(catalog), newServer(catalog)];
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'));
Expand Down
Loading
Loading