From 09c01f2c53129e12bf91e34c8d5dbb917a6b2198 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 8 Oct 2026 14:34:11 -0700 Subject: [PATCH 1/2] feat: server and client extensions, request middleware (use), acceptResultType ServerOptions.extensions and ClientOptions.extensions take extension objects ({ id, install }). Each is advertised under capabilities.extensions[id] and installed at construction; capability settings are registered inside install with registerCapabilities. Protocol.use(method, (request, ctx, next) => ...) is request middleware around the registered handler, Koa-shaped, composed at dispatch time in registration order. Method names are exact, no wildcard. Protocol.acceptResultType(method, resultType) lets a client extension receive its own result kind (the Tasks extension's resultType "task" on tools/call), validated against the caller's explicit result schema. McpServer tool dispatch re-throws MissingRequiredClientCapabilityError (-32021) as a JSON-RPC error instead of an isError tool result. --- .changeset/server-extensions.md | 9 ++ docs/.vitepress/nav.ts | 1 + docs/advanced/extensions.md | 89 ++++++++++ packages/client/src/client/client.ts | 17 ++ packages/client/src/client/extension.ts | 32 ++++ packages/client/src/index.ts | 1 + .../client/test/client/extensions.test.ts | 145 +++++++++++++++++ packages/core-internal/src/shared/protocol.ts | 128 ++++++++++++++- .../test/shared/requestMiddleware.test.ts | 153 ++++++++++++++++++ packages/server/src/index.ts | 1 + packages/server/src/server/extension.ts | 32 ++++ packages/server/src/server/mcp.ts | 8 +- packages/server/src/server/server.ts | 18 +++ .../server/test/server/extensions.test.ts | 118 ++++++++++++++ 14 files changed, 749 insertions(+), 3 deletions(-) create mode 100644 .changeset/server-extensions.md create mode 100644 docs/advanced/extensions.md create mode 100644 packages/client/src/client/extension.ts create mode 100644 packages/client/test/client/extensions.test.ts create mode 100644 packages/core-internal/test/shared/requestMiddleware.test.ts create mode 100644 packages/server/src/server/extension.ts create mode 100644 packages/server/test/server/extensions.test.ts diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md new file mode 100644 index 0000000000..1d00f8d18a --- /dev/null +++ b/.changeset/server-extensions.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Server and client extensions (experimental: the `extensions` option, the extension shapes, `use` and `acceptResultType` may change in a minor release). `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction; everything else an extension does, settings included, happens in `install`. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.use(method, (request, ctx, next) => …)` — middleware around the registered handler, composed at dispatch time in registration order (so middleware on `tools/call` applies even though `McpServer` registers that handler lazily); it returns a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`. + +`McpServer` tool dispatch now re-throws `MissingRequiredClientCapabilityError` (`-32021`) as a JSON-RPC error instead of converting it into an `isError` tool result, matching the existing `UrlElicitationRequiredError` passthrough. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 02ab4d2ff6..f1d5f96cdf 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -66,6 +66,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ items: [ { text: 'Low-level server', link: '/advanced/low-level-server' }, { text: 'Custom methods', link: '/advanced/custom-methods' }, + { text: 'Server extensions', link: '/advanced/extensions' }, { text: 'Schema libraries', link: '/advanced/schema-libraries' }, { text: 'Custom transports', link: '/advanced/custom-transports' }, { text: 'Wire schemas', link: '/advanced/wire-schemas' }, diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md new file mode 100644 index 0000000000..f7fcecff30 --- /dev/null +++ b/docs/advanced/extensions.md @@ -0,0 +1,89 @@ +--- +shape: how-to +--- + +# Server extensions + +::: warning Experimental +The `extensions` option, the `ServerExtension` and `ClientExtension` shapes, `use` and `acceptResultType` are experimental and may change in a minor release. +::: + +A **server extension** packages protocol behaviour outside the core specification — an MCP extension such as `io.modelcontextprotocol/tasks`, or a vendor feature — as one object you pass to the server. The SDK advertises it, installs it, and gives it two hooks: custom methods and middleware on spec methods. What the extension does behind those hooks is its own business. + +## Write an extension + +An extension is an `id` and an `install` function that receives the low-level `Server`. Everything it does to the protocol happens in `install`. + +```ts +import type { ServerExtension } from '@modelcontextprotocol/server'; +import { MissingRequiredClientCapabilityError, CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const GATE = 'com.example/gate'; + +export const gate: ServerExtension = { + id: GATE, + install(server) { + // Settings for the advertised capability, if the extension has any. + server.registerCapabilities({ extensions: { [GATE]: { exampleData: true } } }); + + // A custom method, exactly as in Custom methods. + server.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); + + // Middleware on a spec method: runs around the registered handler, + // may answer, transform, or refuse. + server.use('tools/call', (request, ctx, next) => { + const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]?.extensions ?? {}; + if (!(GATE in declared)) { + throw new MissingRequiredClientCapabilityError({ requiredCapabilities: { extensions: { [GATE]: {} } } }, 'declare the gate'); + } + return next(request, ctx); + }); + } +}; +``` + +## Install it + +Pass extensions at construction. Each is advertised under `capabilities.extensions[id]` as `{}` — legacy connections see it in the `initialize` result, 2026-07-28 connections in `server/discover` — and then installed in order, after the built-in handlers exist. An extension with settings registers them in `install`, as above; the maps merge. + +```ts +const server = new McpServer({ name: 'gated', version: '1.0.0' }, { extensions: [gate] }); +``` + +The same option exists on the low-level `Server`. + +## Client extensions + +The client half is symmetric: `ClientExtension` is `{ id, install(client) }`, passed in `ClientOptions.extensions`. The client advertises it under its own `capabilities.extensions[id]` — in `initialize` on a legacy connection, and in every request's `_meta` client-capabilities envelope on a 2026-07-28 connection, which is where a server extension reads it — and `install` receives the `Client` to register handlers for server-to-client requests and notifications, or wrap the ones the SDK installs with middleware. + +```ts +import type { ClientExtension } from '@modelcontextprotocol/client'; + +const gateClient: ClientExtension = { + id: GATE, + install(client) { + client.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); + // Results of tools/call may carry the extension's own kind; the + // explicit-schema request() path then hands them to the caller's + // schema as-is instead of rejecting the unknown resultType. + client.acceptResultType('tools/call', 'gate'); + } +}; + +const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensions: [gateClient] }); +``` + +## How middleware composes + +`setRequestHandler` is the route handler; `use(method, middleware)` is the middleware around it, Koa-shaped: `await next(request, ctx)` yields the result and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: middleware installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several middleware nest in registration order, the first installed outermost. The returned function removes the middleware. Method names are exact; there is no wildcard. Something every peer must know about the extension belongs in its advertised capability (`registerCapabilities`), not in per-request `_meta`. Where a result does carry extension `_meta`, the middleware adds its own namespaced key and the encoder still stamps the SDK's reserved keys and `resultType` on top. + +A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool handler, `McpServer` converts most throws into an `isError` tool result; the exceptions are protocol-level errors the client must see as errors — `UrlElicitationRequiredError` and `MissingRequiredClientCapabilityError` (`-32021`). + +## Recap + +- `ServerExtension` is `{ id, install(server) }`; pass it in `ServerOptions.extensions`. `ClientExtension` mirrors it on `ClientOptions.extensions`. +- `install` gets the low-level `Server`: `setRequestHandler` for custom methods, `use` for middleware on spec methods, `registerCapabilities` for the extension's settings. +- Middleware composes at dispatch time, in registration order, and applies to handlers registered later. +- `acceptResultType(method, resultType)` lets a client extension receive a result kind outside `complete` / `input_required` through the explicit-schema `request()` path. +- The SDK owns the hooks and the capability advertisement, not the extension's state or execution. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8118b60cea..834016922f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -80,6 +80,7 @@ import { SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; +import type { ClientExtension } from './extension'; import type { PriorDiscovery } from './probeClassifier'; import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache'; import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache'; @@ -181,6 +182,17 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e } export type ClientOptions = ProtocolOptions & { + /** + * Extensions to install at construction. Each is advertised under + * `capabilities.extensions[extension.id]` and then installed, in order — + * see {@linkcode ClientExtension}. + * + * Experimental: the shape of an extension may change in a minor release. + * + * @experimental + */ + extensions?: ClientExtension[]; + /** * Capabilities to advertise as being supported by this client. */ @@ -658,6 +670,11 @@ export class Client extends Protocol { if (options?.listChanged) { this._listChangedConfig = options.listChanged; } + + for (const extension of options?.extensions ?? []) { + this.registerCapabilities({ extensions: { [extension.id]: {} } }); + extension.install(this); + } } protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext { diff --git a/packages/client/src/client/extension.ts b/packages/client/src/client/extension.ts new file mode 100644 index 0000000000..582033dd57 --- /dev/null +++ b/packages/client/src/client/extension.ts @@ -0,0 +1,32 @@ +import type { Client } from './client'; + +/** + * A client extension: the client half of protocol behaviour outside the + * core specification (an MCP extension such as `io.modelcontextprotocol/tasks`, + * or a vendor feature) that installs itself onto a {@linkcode Client}. + * + * Pass extensions at construction — `new Client(info, { extensions: [ext] })`. + * The client advertises each extension under `capabilities.extensions[id]` + * (as `{}`: supported, no settings) + * (in `initialize` on a legacy connection, in every request's + * `_meta` client-capabilities envelope on a 2026-07-28 connection) and then + * calls `install`, which is where the extension registers handlers for + * server-to-client requests and notifications, or wraps the ones the SDK + * installs with middleware (`client.use('elicitation/create', …)`). + * The SDK provides the hooks; what an extension does behind them is its own. + * + * Experimental: the shape of an extension may change in a minor release. + * + * @experimental + */ +export interface ClientExtension { + /** + * The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`, + * `com.example/feature-flags`). Advertised as the key under + * `capabilities.extensions`. Settings for that key, if any, are the + * extension's to register in `install` via `registerCapabilities`. + */ + readonly id: string; + /** Installs the extension's handlers and middleware onto the client. Called once, at construction. */ + install(client: Client): void; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0b5b6e86ea..12c2667b7f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -68,6 +68,7 @@ export { StaticPrivateKeyJwtProvider } from './client/authExtensions'; export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client'; +export type { ClientExtension } from './client/extension'; export { Client } from './client/client'; export { getSupportedElicitationModes } from './client/client'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess'; diff --git a/packages/client/test/client/extensions.test.ts b/packages/client/test/client/extensions.test.ts new file mode 100644 index 0000000000..065af3d88c --- /dev/null +++ b/packages/client/test/client/extensions.test.ts @@ -0,0 +1,145 @@ +/** + * `ClientOptions.extensions`: an extension is advertised under the client's + * `capabilities.extensions[id]` — in `initialize` on a legacy connection, in + * every request's `_meta` client-capabilities envelope on a 2026-07-28 + * connection — and installed at construction, where it can register + * handlers for server-to-client requests. + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { CLIENT_CAPABILITIES_META_KEY, InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { Client } from '../../src/client/client'; +import type { ClientExtension } from '../../src/client/extension'; + +const MODERN = '2026-07-28'; +const EXT_ID = 'com.example/gate'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + +function gateExtension(log: string[]): ClientExtension { + return { + id: EXT_ID, + install(client) { + log.push('installed'); + client.registerCapabilities({ extensions: { [EXT_ID]: { exampleData: true } } }); + client.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); + client.acceptResultType('tools/call', 'task'); + } + }; +} + +/** A scripted server side: answers the handshake for the requested era and records everything the client writes. */ +async function scriptedServer(era: 'modern' | 'legacy') { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const request = message as { id?: number | string; method?: string }; + if (request.id === undefined) return; + if (request.method === 'server/discover') { + void serverTx.send( + era === 'modern' + ? { + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted', version: '1.0.0' } } + } + } + : { jsonrpc: '2.0', id: request.id, error: { code: -32_601, message: 'Method not found' } } + ); + } else if (request.method === 'initialize') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'scripted', version: '1.0.0' } + } + }); + } else if (request.method === 'tools/call') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { resultType: 'task', taskId: 't-1', status: 'working', createdAt: 'now', lastUpdatedAt: 'now', ttlMs: null } + }); + } else if (request.method === 'tools/list') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: era === 'modern' ? { resultType: 'complete', tools: [], ttlMs: 0, cacheScope: 'public' } : { tools: [] } + }); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + +const paramsOf = (message: JSONRPCMessage): Record => (message as { params?: Record }).params ?? {}; + +describe('ClientOptions.extensions', () => { + it('installs the extension at construction', () => { + const log: string[] = []; + new Client({ name: 'c', version: '1' }, { extensions: [gateExtension(log)] }); + expect(log).toEqual(['installed']); + }); + + it('advertises {} when install sets no settings', async () => { + const { clientTx, written } = await scriptedServer('legacy'); + const client = new Client({ name: 'c', version: '1' }, { extensions: [{ id: 'com.example/plain', install: () => {} }] }); + await client.connect(clientTx); + const initialize = written.find(message => (message as { method?: string }).method === 'initialize'); + expect(paramsOf(initialize as JSONRPCMessage)['capabilities']).toMatchObject({ extensions: { 'com.example/plain': {} } }); + await client.close(); + }); + + it('sends the extension in initialize on a legacy connection', async () => { + const { clientTx, written } = await scriptedServer('legacy'); + const client = new Client({ name: 'c', version: '1' }, { extensions: [gateExtension([])] }); + await client.connect(clientTx); + const initialize = written.find(message => (message as { method?: string }).method === 'initialize'); + expect(paramsOf(initialize as JSONRPCMessage)['capabilities']).toMatchObject({ extensions: { [EXT_ID]: { exampleData: true } } }); + await client.close(); + }); + + it('stamps the extension into every request envelope on a 2026-07-28 connection', async () => { + const { clientTx, written } = await scriptedServer('modern'); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' }, extensions: [gateExtension([])] }); + await client.connect(clientTx); + await client.listTools(); + await flush(); + const toolsList = written.find(message => (message as { method?: string }).method === 'tools/list'); + const meta = paramsOf(toolsList as JSONRPCMessage)['_meta'] as Record; + expect(meta[CLIENT_CAPABILITIES_META_KEY]).toMatchObject({ extensions: { [EXT_ID]: { exampleData: true } } }); + await client.close(); + }); + + it('receives an extension result kind the extension accepted, discriminator included', async () => { + const { clientTx } = await scriptedServer('modern'); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' }, extensions: [gateExtension([])] }); + await client.connect(clientTx); + const taskSchema = z.looseObject({ resultType: z.literal('task'), taskId: z.string(), status: z.string() }); + const result = await client.request({ method: 'tools/call', params: { name: 'slow', arguments: {} } }, taskSchema); + expect(result).toMatchObject({ resultType: 'task', taskId: 't-1', status: 'working' }); + // callTool validates against CallToolResultSchema, which a task handle does not satisfy. + await expect(client.callTool({ name: 'slow', arguments: {} })).rejects.toThrow(/Invalid result for tools\/call/); + await client.close(); + }); + + it('serves the handler the extension installed for a server-to-client request', async () => { + const { clientTx, serverTx, written } = await scriptedServer('legacy'); + const client = new Client({ name: 'c', version: '1' }, { extensions: [gateExtension([])] }); + await client.connect(clientTx); + await serverTx.send({ jsonrpc: '2.0', id: 'srv-1', method: 'gate/ping', params: {} }); + await flush(); + const response = written.find(message => 'id' in message && message.id === 'srv-1'); + expect((response as { result?: unknown }).result).toEqual({ pong: true }); + await client.close(); + }); +}); diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 9d60be8efe..ecc7f44025 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -205,6 +205,17 @@ const RESERVED_ENVELOPE_META_KEYS: readonly string[] = [ */ const RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] as const; +/** + * Request middleware (see `Protocol.use`): receives the parsed request, the + * context, and `next` — the handler it wraps — and returns the result that + * goes on the wire. + */ +export type RequestMiddleware = ( + request: JSONRPCRequest, + ctx: ContextT, + next: (request: JSONRPCRequest, ctx: ContextT) => Promise +) => Result | Promise; + /** * Lift wire-only material out of an inbound message so handlers see exactly * the 2025-era shape, and surface it for the protocol layer (requests: via @@ -568,6 +579,10 @@ export abstract class Protocol { private _requestHandlers: Map Promise> = new Map(); /** Methods registered with an explicit schema; the era gate in `_onrequest` reads it for the Tasks extension names. */ private _customSchemaRequestMethods = new Set(); + /** Middleware installed by `use`, in registration order across every method (see `_resolveRequestHandler`). */ + private _requestMiddleware: Array<{ method: string; middleware: RequestMiddleware }> = []; + /** Extension result kinds accepted per method (see `acceptResultType`). */ + private _acceptedResultTypes: Map> = new Map(); private _requestHandlerAbortControllers: Map = new Map(); private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); @@ -1019,7 +1034,7 @@ export abstract class Protocol { return; } - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + const handler = this._resolveRequestHandler(request.method); if (handler === undefined) { sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); @@ -1530,6 +1545,21 @@ export abstract class Protocol { // `_onresponse`, so a throw out of the decode hop would // otherwise propagate into the transport's onmessage instead // of failing this request. + // Extension result kinds (see `acceptResultType`): a raw + // `resultType` the caller declared for this method bypasses + // the codec's closed vocabulary and reaches the caller's + // schema as-is, discriminator included. + if (this._isAcceptedResultType(request.method, response.result)) { + validateStandardSchema(resultSchema, response.result).then(parseResult => { + if (parseResult.success) { + resolve(parseResult.data); + } else { + reject(new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: ${parseResult.error}`)); + } + }, reject); + return; + } + let decoded: ReturnType; try { decoded = codec.decodeResult(request.method, response.result); @@ -1777,6 +1807,102 @@ export abstract class Protocol { this._requestHandlers.set(method, this._wrapHandler(method, stored)); } + /** + * Installs middleware on the request handler for `method`. The middleware + * receives the parsed request, the context, and `next` — the handler it + * wraps — and may answer itself, transform what `next` returns, or throw + * a `ProtocolError` that becomes the JSON-RPC error response. + * `setRequestHandler` is the route handler; this is the middleware + * around it, Koa-shaped: `await next(request, ctx)` yields the result and + * the middleware returns what goes on the wire. + * + * Middleware composes at dispatch time, so middleware installed before + * the underlying handler exists (e.g. `tools/call`, which `McpServer` + * registers on the first tool registration) still applies; with no + * underlying handler and no fallback, `next` throws `MethodNotFound`. + * Middleware runs in registration order: the first installed is the + * outermost. Method names are exact — there is no wildcard; what an + * extension needs every peer to know goes through `registerCapabilities`, + * not per-request middleware. This is the hook extensions use to + * intercept spec methods. + * + * @returns A function that removes the middleware. + * + * Experimental: this method may change in a minor release. + * + * @experimental + */ + use(method: RequestMethod | string, middleware: RequestMiddleware): () => void { + if (typeof middleware !== 'function') { + throw new TypeError('use: middleware is required'); + } + const entry = { method, middleware }; + this._requestMiddleware.push(entry); + return () => { + const index = this._requestMiddleware.indexOf(entry); + if (index !== -1) this._requestMiddleware.splice(index, 1); + }; + } + + /** + * Declares that results of `method` may carry `resultType` — a kind an + * extension defines beyond the spec's `complete` / `input_required` + * vocabulary (the Tasks extension answers `tools/call` with + * `resultType: "task"`). A raw response with that discriminator skips the + * era codec's decode and is validated against the caller's explicit + * result schema as-is, discriminator included. Only the explicit-schema + * `request(request, resultSchema)` path consults this; typed spec calls + * keep the closed vocabulary. This is the hook client extensions use to + * receive extension result shapes. + * + * @returns A function that withdraws the declaration. + * + * Experimental: this method may change in a minor release. + * + * @experimental + */ + acceptResultType(method: string, resultType: string): () => void { + const accepted = this._acceptedResultTypes.get(method) ?? new Set(); + accepted.add(resultType); + this._acceptedResultTypes.set(method, accepted); + return () => { + const current = this._acceptedResultTypes.get(method); + current?.delete(resultType); + if (current?.size === 0) this._acceptedResultTypes.delete(method); + }; + } + + private _isAcceptedResultType(method: string, raw: unknown): boolean { + const accepted = this._acceptedResultTypes.get(method); + if (accepted === undefined || !isPlainObject(raw)) return false; + const resultType = raw['resultType']; + return typeof resultType === 'string' && accepted.has(resultType); + } + + /** + * The handler `_onrequest` dispatches to for `method`: the registered + * handler (or the fallback), with any middleware for the method composed + * around it in registration order (first registered outermost). + * `undefined` when nothing is registered and no middleware applies. + */ + private _resolveRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { + const base = this._requestHandlers.get(method) ?? this.fallbackRequestHandler; + const stack = this._requestMiddleware.filter(entry => entry.method === method); + if (stack.length === 0) return base; + let composed: (request: JSONRPCRequest, ctx: ContextT) => Promise = + base ?? + (async () => { + throw new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); + }); + // Wrap from the innermost (last registered) outwards so the first + // registered middleware ends up outermost. + for (const { middleware } of stack.toReversed()) { + const next = composed; + composed = async (request, ctx) => middleware(request, ctx, next); + } + return composed; + } + /** * Hook for subclasses to wrap a registered request handler with role-specific * validation or behavior (e.g. `Server` validates `tools/call` results, `Client` diff --git a/packages/core-internal/test/shared/requestMiddleware.test.ts b/packages/core-internal/test/shared/requestMiddleware.test.ts new file mode 100644 index 0000000000..f3d803fc35 --- /dev/null +++ b/packages/core-internal/test/shared/requestMiddleware.test.ts @@ -0,0 +1,153 @@ +/** + * `Protocol.use`: middleware composes around the registered handler at + * dispatch time, may answer or throw itself, applies to handlers registered + * later, runs in registration order, and can be removed. + */ +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/shared/protocol'; +import { Protocol } from '../../src/shared/protocol'; +import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCResultResponse, Result } from '../../src/types/index'; +import { ProtocolError, ProtocolErrorCode } from '../../src/types/index'; +import { InMemoryTransport } from '../../src/util/inMemory'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 5)); + +async function harness() { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + const protocol = new TestProtocol(); + await protocol.connect(protocolTx); + let nextId = 0; + const call = async (method: string, params: Record = {}) => { + const id = ++nextId; + await peerTx.send({ jsonrpc: '2.0', id, method, params }); + await flush(); + const response = sent.find(message => 'id' in message && message.id === id); + return response as JSONRPCResultResponse | JSONRPCErrorResponse; + }; + return { protocol, call }; +} + +describe('Protocol.use (request middleware)', () => { + it('wraps the registered handler; next reaches it and the middleware may transform the result', async () => { + const { protocol, call } = await harness(); + protocol.setRequestHandler('acme/op', { params: z.looseObject({}) }, () => ({ value: 1 }) as Result); + protocol.use('acme/op', async (request, ctx, next) => { + const result = (await next(request, ctx)) as { value: number }; + return { value: result.value + 1, wrapped: true } as Result; + }); + const response = await call('acme/op'); + expect((response as JSONRPCResultResponse).result).toEqual({ value: 2, wrapped: true }); + }); + + it('may answer without calling next, and a thrown ProtocolError becomes the JSON-RPC error', async () => { + const { protocol, call } = await harness(); + let handlerRan = false; + protocol.setRequestHandler('acme/op', { params: z.looseObject({ deny: z.boolean().optional() }) }, () => { + handlerRan = true; + return {} as Result; + }); + protocol.use('acme/op', (request, ctx, next) => { + if ((request.params as { deny?: boolean }).deny) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'denied by middleware'); + } + return next(request, ctx); + }); + const denied = await call('acme/op', { deny: true }); + expect((denied as JSONRPCErrorResponse).error).toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: 'denied by middleware' + }); + expect(handlerRan).toBe(false); + await call('acme/op'); + expect(handlerRan).toBe(true); + }); + + it('applies to a handler registered after the middleware was installed', async () => { + const { protocol, call } = await harness(); + const order: string[] = []; + protocol.use('acme/late', async (request, ctx, next) => { + order.push('middleware'); + return next(request, ctx); + }); + protocol.setRequestHandler('acme/late', { params: z.looseObject({}) }, () => { + order.push('handler'); + return {} as Result; + }); + await call('acme/late'); + expect(order).toEqual(['middleware', 'handler']); + }); + + it('keeps one registration order across methods and stamps _meta through next', async () => { + const { protocol, call } = await harness(); + const order: string[] = []; + for (const method of ['acme/a', 'acme/b']) { + protocol.setRequestHandler(method, { params: z.looseObject({}) }, () => { + order.push(`handler:${method}`); + return { method } as Result; + }); + } + protocol.use('acme/a', async (request, ctx, next) => { + order.push('a-1'); + const result = await next(request, ctx); + return { ...result, _meta: { 'acme/stamped': true } } as Result; + }); + protocol.use('acme/b', async (request, ctx, next) => { + order.push('b-1'); + return next(request, ctx); + }); + protocol.use('acme/a', async (request, ctx, next) => { + order.push('a-2'); + return next(request, ctx); + }); + const a = await call('acme/a'); + expect((a as JSONRPCResultResponse).result).toEqual({ method: 'acme/a', _meta: { 'acme/stamped': true } }); + expect(order).toEqual(['a-1', 'a-2', 'handler:acme/a']); + order.length = 0; + await call('acme/b'); + expect(order).toEqual(['b-1', 'handler:acme/b']); + }); + + it('next throws MethodNotFound when nothing underlies the middleware', async () => { + const { protocol, call } = await harness(); + protocol.use('acme/missing', (request, ctx, next) => next(request, ctx)); + const response = await call('acme/missing'); + expect((response as JSONRPCErrorResponse).error).toMatchObject({ code: ProtocolErrorCode.MethodNotFound }); + }); + + it('runs in registration order (first registered outermost), and removal restores the handler', async () => { + const { protocol, call } = await harness(); + const order: string[] = []; + protocol.setRequestHandler('acme/op', { params: z.looseObject({}) }, () => { + order.push('handler'); + return {} as Result; + }); + const removeFirst = protocol.use('acme/op', async (request, ctx, next) => { + order.push('first'); + return next(request, ctx); + }); + protocol.use('acme/op', async (request, ctx, next) => { + order.push('second'); + return next(request, ctx); + }); + await call('acme/op'); + expect(order).toEqual(['first', 'second', 'handler']); + order.length = 0; + removeFirst(); + await call('acme/op'); + expect(order).toEqual(['second', 'handler']); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 32605dd7e7..d6440e6c4c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -56,6 +56,7 @@ export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerR export { PerRequestHTTPServerTransport } from './server/perRequestTransport'; // Opt-in HMAC sealing for the multi-round-trip requestState (SEP-2322): the // convenience codec consumers drop into ServerOptions.requestState.verify. +export type { ServerExtension } from './server/extension'; export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec'; export { createRequestStateCodec } from './server/requestStateCodec'; export type { ServerOptions } from './server/server'; diff --git a/packages/server/src/server/extension.ts b/packages/server/src/server/extension.ts new file mode 100644 index 0000000000..99a8db6fdb --- /dev/null +++ b/packages/server/src/server/extension.ts @@ -0,0 +1,32 @@ +import type { Server } from './server'; + +/** + * A server extension: a unit of protocol behaviour outside the core + * specification (an MCP extension such as `io.modelcontextprotocol/tasks`, + * or a vendor feature) that installs itself onto a {@linkcode Server}. + * + * Pass extensions at construction — `new McpServer(info, { extensions: [ext] })` + * or `new Server(info, { extensions: [ext] })`. The server advertises each + * extension under `capabilities.extensions[id]` and then calls `install`, + * which is where the extension registers its custom methods + * (`server.setRequestHandler(method, { params, result }, handler)`), + * installs middleware on spec methods it needs to intercept + * (`server.use('tools/call', …)`), and adds notification + * handlers. The SDK provides the hooks; what an extension does behind them + * — how it stores state, where its work runs — is the extension's own. + * + * Experimental: the shape of an extension may change in a minor release. + * + * @experimental + */ +export interface ServerExtension { + /** + * The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`, + * `com.example/feature-flags`). Advertised as the key under + * `capabilities.extensions`. Settings for that key, if any, are the + * extension's to register in `install` via `registerCapabilities`. + */ + readonly id: string; + /** Installs the extension's handlers and middleware onto the server. Called once, at construction. */ + install(server: Server): void; +} diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 57b7c132eb..d664929f8e 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -314,8 +314,12 @@ export class McpServer { // wrapped) so the listing and the call cannot diverge. return this.server.projectCallToolResult(result, tool.outputSchemaJson); } catch (error) { - if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { - throw error; // Return the error to the caller without wrapping in CallToolResult + if ( + error instanceof ProtocolError && + (error.code === ProtocolErrorCode.UrlElicitationRequired || + error.code === ProtocolErrorCode.MissingRequiredClientCapability) + ) { + throw error; // Protocol-level: return the error to the caller without wrapping in CallToolResult } return this.createToolError(error instanceof Error ? error.message : String(error)); } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 032fdc9c7f..1fee841c88 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -73,7 +73,20 @@ import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimO */ const INPUT_REQUIRED_CAPABLE_METHODS: ReadonlySet = new Set(['tools/call', 'prompts/get', 'resources/read']); +import type { ServerExtension } from './extension'; + export type ServerOptions = ProtocolOptions & { + /** + * Extensions to install at construction. Each is advertised under + * `capabilities.extensions[extension.id]` and then installed, in order, + * after the built-in handlers exist — see {@linkcode ServerExtension}. + * + * Experimental: the shape of an extension may change in a minor release. + * + * @experimental + */ + extensions?: ServerExtension[]; + /** * Capabilities to advertise as being supported by this server. * @@ -350,6 +363,11 @@ export class Server extends Protocol { if (this._capabilities.logging) { this._registerLoggingHandler(); } + + for (const extension of options?.extensions ?? []) { + this.registerCapabilities({ extensions: { [extension.id]: {} } }); + extension.install(this); + } } /** Attaches to the given transport; rejects while this instance is connected to another one. */ diff --git a/packages/server/test/server/extensions.test.ts b/packages/server/test/server/extensions.test.ts new file mode 100644 index 0000000000..9a388d6e73 --- /dev/null +++ b/packages/server/test/server/extensions.test.ts @@ -0,0 +1,118 @@ +/** + * `ServerOptions.extensions`: an extension is advertised under + * `capabilities.extensions[id]` and installed at construction, where it can + * register custom methods and install middleware on spec methods such as `tools/call`. + */ +import type { JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + MissingRequiredClientCapabilityError, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { ServerExtension } from '../../src/server/extension'; +import { invoke } from '../../src/server/invoke'; +import { McpServer } from '../../src/server/mcp'; +import { Server } from '../../src/server/server'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; +const EXT_ID = 'com.example/gate'; + +const modernRequest = ( + method: string, + params: Record = {}, + clientCapabilities: Record = {} +): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id: 1, + method, + params: { + ...params, + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'ext-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: clientCapabilities + } + } + }) as JSONRPCRequest; + +async function exchange(server: Server, request: JSONRPCRequest): Promise> { + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const response = await invoke(server, request, { classification: MODERN }); + return (await response.json()) as Record; +} + +/** An extension that gates `tools/call` on a client capability and adds one custom method. */ +function gateExtension(log: string[]): ServerExtension { + return { + id: EXT_ID, + install(server) { + log.push('installed'); + server.registerCapabilities({ extensions: { [EXT_ID]: { exampleData: true } } }); + server.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); + server.use('tools/call', (request, ctx, next) => { + const envelope = ctx.mcpReq.envelope as Record> | undefined; + const extensions = envelope?.[CLIENT_CAPABILITIES_META_KEY]?.['extensions'] as Record | undefined; + if (extensions === undefined || !(EXT_ID in extensions)) { + throw new MissingRequiredClientCapabilityError( + { requiredCapabilities: { extensions: { [EXT_ID]: {} } } }, + 'declare the gate' + ); + } + return next(request, ctx); + }); + } + }; +} + +describe('ServerOptions.extensions', () => { + it('advertises the extension and lets install set its settings', () => { + const log: string[] = []; + const server = new Server({ name: 's', version: '1' }, { extensions: [gateExtension(log)] }); + expect(log).toEqual(['installed']); + expect(server.getCapabilities().extensions).toEqual({ [EXT_ID]: { exampleData: true } }); + }); + + it('advertises {} when install sets no settings, and passes through McpServer', () => { + const ext: ServerExtension = { id: 'com.example/plain', install: () => {} }; + const mcp = new McpServer({ name: 's', version: '1' }, { extensions: [ext] }); + expect(mcp.server.getCapabilities().extensions).toEqual({ 'com.example/plain': {} }); + }); + + it('serves the extension custom method', async () => { + const server = new Server({ name: 's', version: '1' }, { extensions: [gateExtension([])] }); + const body = await exchange(server, modernRequest('gate/status')); + expect(body['result']).toMatchObject({ armed: true }); + }); + + it('middleware on tools/call registered later by McpServer: refuses without the capability, passes through with it', async () => { + const mcp = new McpServer({ name: 's', version: '1' }, { extensions: [gateExtension([])] }); + mcp.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + + const refused = await exchange(mcp.server, modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } })); + expect(refused['error']).toMatchObject({ code: -32_021, message: 'declare the gate' }); + + const allowed = await exchange( + mcp.server, + modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } }, { extensions: { [EXT_ID]: {} } }) + ); + expect(allowed['result']).toMatchObject({ content: [{ type: 'text', text: 'hi' }] }); + }); + + it('a MissingRequiredClientCapabilityError thrown by a tool handler is a JSON-RPC error, not an isError result', async () => { + const mcp = new McpServer({ name: 's', version: '1' }); + mcp.registerTool('needs-ext', {}, async () => { + throw new MissingRequiredClientCapabilityError({ requiredCapabilities: { extensions: { [EXT_ID]: {} } } }, 'declare the gate'); + }); + const body = await exchange(mcp.server, modernRequest('tools/call', { name: 'needs-ext', arguments: {} })); + expect(body['error']).toMatchObject({ code: -32_021, data: { requiredCapabilities: { extensions: { [EXT_ID]: {} } } } }); + }); +}); From 0f7df1f409a2c38a9f8c12f1a60729a045cbfc2d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 8 Oct 2026 15:49:14 -0700 Subject: [PATCH 2/2] feat: Skills extension for server and client (ext/skills subpaths) @modelcontextprotocol/server/ext/skills adds SkillsExtension(source): it declares io.modelcontextprotocol/skills and the resources capability, and serves skills/list and skills/get from a SkillSource, plus resources/directory/read (declared as directoryRead) when the source implements readDirectory. Entries are checked against the structural rules of SEP-2640 before they are sent; an invalid one answers -32603. Skill files stay ordinary resources; skillResourceOf computes the digest and size an entry lists for each. @modelcontextprotocol/client/ext/skills adds SkillsClientExtension: list, get and readDirectory, refused unless the server declared support, and read(skill, uri), which fetches a file and verifies it is listed and matches the entry's size and digest. Wire types and schemas live at @modelcontextprotocol/core-internal/ext/skills. The schemas and constants are adapted from #2818. Co-authored-by: tobi-oye --- .changeset/skills-extension.md | 13 ++ docs/.vitepress/nav.ts | 2 + docs/clients/skills.md | 39 ++++ docs/servers/skills.md | 57 +++++ packages/client/package.json | 13 ++ packages/client/src/ext/skills/index.ts | 44 ++++ .../src/ext/skills/skillsClientExtension.ts | 133 ++++++++++++ packages/client/tsconfig.json | 3 +- packages/client/tsdown.config.ts | 2 + packages/core-internal/package.json | 4 + .../core-internal/src/ext/skills/digest.ts | 20 ++ .../core-internal/src/ext/skills/index.ts | 8 + .../core-internal/src/ext/skills/schemas.ts | 97 +++++++++ .../core-internal/src/ext/skills/types.ts | 106 +++++++++ .../test/ext/skills/schemas.test.ts | 37 ++++ .../test/packageTopologyPins.test.ts | 4 +- packages/server/package.json | 14 ++ packages/server/src/ext/skills/index.ts | 46 ++++ .../server/src/ext/skills/skillsExtension.ts | 130 +++++++++++ .../server/test/ext/skills/skills.e2e.test.ts | 203 ++++++++++++++++++ packages/server/tsconfig.json | 8 +- packages/server/tsdown.config.ts | 2 + pnpm-lock.yaml | 3 + 23 files changed, 984 insertions(+), 4 deletions(-) create mode 100644 .changeset/skills-extension.md create mode 100644 docs/clients/skills.md create mode 100644 docs/servers/skills.md create mode 100644 packages/client/src/ext/skills/index.ts create mode 100644 packages/client/src/ext/skills/skillsClientExtension.ts create mode 100644 packages/core-internal/src/ext/skills/digest.ts create mode 100644 packages/core-internal/src/ext/skills/index.ts create mode 100644 packages/core-internal/src/ext/skills/schemas.ts create mode 100644 packages/core-internal/src/ext/skills/types.ts create mode 100644 packages/core-internal/test/ext/skills/schemas.test.ts create mode 100644 packages/server/src/ext/skills/index.ts create mode 100644 packages/server/src/ext/skills/skillsExtension.ts create mode 100644 packages/server/test/ext/skills/skills.e2e.test.ts diff --git a/.changeset/skills-extension.md b/.changeset/skills-extension.md new file mode 100644 index 0000000000..dc8d7c4b6f --- /dev/null +++ b/.changeset/skills-extension.md @@ -0,0 +1,13 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +The MCP Skills extension (`io.modelcontextprotocol/skills`, SEP-2640) as a pair of extensions. + +`@modelcontextprotocol/server/ext/skills`: `new SkillsExtension(source)` in `ServerOptions.extensions` declares the extension and the `resources` capability, and serves `skills/list` and `skills/get` from a `SkillSource`, plus `resources/directory/read` (declared as `directoryRead`) when the source implements `readDirectory`. Entries are checked against the specification's structural rules before they are sent. Skill files stay ordinary resources; `skillResourceOf` computes the digest and size an entry lists for each. + +`@modelcontextprotocol/client/ext/skills`: `new SkillsClientExtension()` in `ClientOptions.extensions` wraps `list`, `get` and `readDirectory`, each refused unless the server declared support, and `read(skill, uri)`, which fetches a skill file and verifies it is listed and matches the entry's size and digest. + +Wire types and zod schemas live at `@modelcontextprotocol/core-internal/ext/skills` and are re-exported from both subpaths. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index f1d5f96cdf..55188dbfe2 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -27,6 +27,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Elicitation', link: '/servers/elicitation' }, { text: 'Sampling (sunset)', link: '/servers/sampling' }, { text: 'Input required', link: '/servers/input-required' }, + { text: 'Skills (extension)', link: '/servers/skills' }, { text: 'Notifications', link: '/servers/notifications' }, { text: 'Errors', link: '/servers/errors' } ] @@ -53,6 +54,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Handle server requests', link: '/clients/server-requests' }, { text: 'Roots (sunset)', link: '/clients/roots' }, { text: 'Subscriptions', link: '/clients/subscriptions' }, + { text: 'Skills (extension)', link: '/clients/skills' }, { text: 'OAuth', link: '/clients/oauth' }, { text: 'Machine auth', link: '/clients/machine-auth' }, { text: 'Middleware', link: '/clients/middleware' }, diff --git a/docs/clients/skills.md b/docs/clients/skills.md new file mode 100644 index 0000000000..a77372ce9b --- /dev/null +++ b/docs/clients/skills.md @@ -0,0 +1,39 @@ +--- +shape: how-to +--- + +# Skills (extension) + +The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/). `@modelcontextprotocol/client/ext/skills` is the client side, as a client extension: it lists and gets skill entries, reads directories, and reads skill files verified against their entry. + +## Install the extension + +```ts +import { Client } from '@modelcontextprotocol/client'; +import { SkillsClientExtension } from '@modelcontextprotocol/client/ext/skills'; + +const skills = new SkillsClientExtension(); +const client = new Client({ name: 'host', version: '1.0.0' }, { extensions: [skills] }); +await client.connect(transport); +``` + +Every method refuses with `CapabilityNotSupported` unless the server declared the extension and the `resources` capability. `readDirectory` also needs the server to declare `directoryRead`. + +## List and get skills + +`list` returns one page of entries. Pass `nextCursor` back as `cursor` for the next page. An entry is the full manifest: frontmatter, plus every file with its digest and size. `get(uri)` returns the entry for one skill by the URI of its `SKILL.md`, whether or not the listing included it. + +```ts +const { skills: entries, nextCursor } = await skills.list(); +const { skill } = await skills.get('skill://git-workflow/SKILL.md'); +``` + +## Read a skill file + +`read(skill, uri)` fetches the file with `resources/read` and checks it against the entry. The file must be listed, and its size and SHA-256 digest must match. Any mismatch throws `SdkError` with `InvalidResult`. Refresh the entry with `get` and ask the user to approve the skill again. + +```ts +const manifest = await skills.read(skill, skill.uri); +``` + +A skill with `resources: 'dynamic'` has nothing to verify against, so its files come back as read. The extension leaves two checks to the host: comparing a `SKILL.md`'s frontmatter with the entry (this needs a YAML parser), and the specification's [security requirements](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx#security-considerations) for loading skills into a model. diff --git a/docs/servers/skills.md b/docs/servers/skills.md new file mode 100644 index 0000000000..c303338958 --- /dev/null +++ b/docs/servers/skills.md @@ -0,0 +1,57 @@ +--- +shape: how-to +--- + +# Skills (extension) + +The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/): directories of instructions, each with a `SKILL.md`, that a host can load into its model. `@modelcontextprotocol/server/ext/skills` is the server side, as a [server extension](../advanced/extensions.md). It serves `skills/list`, `skills/get` and, optionally, `resources/directory/read`. Your server supplies the skill entries through a `SkillSource`, and serves the skill files as ordinary resources. + +## Describe a skill + +A skill entry names its `SKILL.md`, repeats that file's frontmatter, and lists every file of the skill with its SHA-256 digest and size. `skillResourceOf(uri, content)` computes a file's line in that list. + +```ts +import type { Skill } from '@modelcontextprotocol/server/ext/skills'; +import { skillResourceOf } from '@modelcontextprotocol/server/ext/skills'; + +const files = { + 'skill://git-workflow/SKILL.md': '---\nname: git-workflow\ndescription: Follow our Git conventions\n---\n\nBranch from main.\n' +}; + +const gitWorkflow: Skill = { + uri: 'skill://git-workflow/SKILL.md', + frontmatter: { name: 'git-workflow', description: 'Follow our Git conventions' }, + resources: await Promise.all(Object.entries(files).map(([uri, text]) => skillResourceOf(uri, text))) +}; +``` + +The frontmatter must match the `SKILL.md` exactly, and the URI's last directory must equal `frontmatter.name`. The server checks the second rule, and the entry's other structural rules, and answers `-32603` rather than send an entry that breaks one. A skill generated per request, with no stable digests, sets `resources: 'dynamic'`. + +## Install the extension + +`SkillsExtension` takes the source. `list` returns a page of entries (an empty or partial listing is allowed). `get` answers for any skill the server serves, listed or not, and returns `undefined` for anything else, which the client receives as `-32602`. + +```ts +import { McpServer } from '@modelcontextprotocol/server'; +import { SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; + +const skills = new SkillsExtension( + { + list: () => ({ skills: [gitWorkflow] }), + get: ({ uri }) => (uri === gitWorkflow.uri ? gitWorkflow : undefined) + }, + { cacheHint: { ttlMs: 300_000, cacheScope: 'public' } } +); + +const server = new McpServer({ name: 'skills-server', version: '1.0.0' }, { extensions: [skills] }); + +for (const [uri, text] of Object.entries(files)) { + server.registerResource(uri, uri, { mimeType: 'text/markdown' }, () => ({ contents: [{ uri, mimeType: 'text/markdown', text }] })); +} +``` + +The server declares `io.modelcontextprotocol/skills` under `capabilities.extensions`, along with the `resources` capability the extension requires. `cacheHint` sets the `ttlMs` and `cacheScope` on `skills/list` and `skills/get` results. Both default to `0` and `'private'`. + +## Serve directory reads + +A source that implements `readDirectory` also gets `resources/directory/read`, and the server declares `directoryRead: true`. It returns the direct children of a directory such as `skill://pdf-processing/templates`, with subdirectories marked `mimeType: 'inode/directory'`, or `undefined` when the URI is not a directory. diff --git a/packages/client/package.json b/packages/client/package.json index 29bdd4f8db..e44cf3735b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -40,6 +40,16 @@ "default": "./dist/stdio.cjs" } }, + "./ext/skills": { + "import": { + "types": "./dist/ext/skills/index.d.mts", + "default": "./dist/ext/skills/index.mjs" + }, + "require": { + "types": "./dist/ext/skills/index.d.cts", + "default": "./dist/ext/skills/index.cjs" + } + }, "./validators/ajv": { "import": { "types": "./dist/validators/ajv.d.mts", @@ -115,6 +125,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "ext/skills": [ + "dist/ext/skills/index.d.mts" ] } }, diff --git a/packages/client/src/ext/skills/index.ts b/packages/client/src/ext/skills/index.ts new file mode 100644 index 0000000000..ed79c04067 --- /dev/null +++ b/packages/client/src/ext/skills/index.ts @@ -0,0 +1,44 @@ +/** + * `@modelcontextprotocol/client/ext/skills` — the client side of the MCP + * Skills extension (`io.modelcontextprotocol/skills`, SEP-2640). + * + * `SkillsClientExtension` wraps `skills/list`, `skills/get` and + * `resources/directory/read`, and reads skill files verified against their + * entry's digest and size. + */ + +export { SkillsClientExtension } from './skillsClientExtension'; +export type { + GetSkillParams, + GetSkillResult, + ListSkillsParams, + ListSkillsResult, + ReadResourceDirectoryParams, + ReadResourceDirectoryResult, + Skill, + SkillFrontmatter, + SkillResource, + SkillsCacheScope, + SkillsExtensionCapability +} from '@modelcontextprotocol/core-internal/ext/skills'; +export { + getSkillParamsSchema, + getSkillResultSchema, + listSkillsParamsSchema, + listSkillsResultSchema, + readResourceDirectoryParamsSchema, + readResourceDirectoryResultSchema, + skillFrontmatterSchema, + skillResourceSchema, + skillSchema, + skillsExtensionCapabilitySchema +} from '@modelcontextprotocol/core-internal/ext/skills'; +export { + DIRECTORY_MIME_TYPE, + MAX_SKILL_RESOURCES, + MAX_SKILL_TOTAL_BYTES, + SKILL_MANIFEST_FILENAME, + skillDigest, + skillResourceOf, + SKILLS_EXTENSION_ID +} from '@modelcontextprotocol/core-internal/ext/skills'; diff --git a/packages/client/src/ext/skills/skillsClientExtension.ts b/packages/client/src/ext/skills/skillsClientExtension.ts new file mode 100644 index 0000000000..7c1bc2a1b7 --- /dev/null +++ b/packages/client/src/ext/skills/skillsClientExtension.ts @@ -0,0 +1,133 @@ +/** + * `SkillsClientExtension` — the client side of the MCP Skills extension + * (`io.modelcontextprotocol/skills`, SEP-2640) as a {@linkcode ClientExtension}. + * It wraps `skills/list`, `skills/get` and `resources/directory/read`, each + * refused unless the server declared support, and `read`, which fetches a + * skill file with `resources/read` and verifies it against the skill's entry. + * + * ```ts + * const skills = new SkillsClientExtension(); + * const client = new Client(info, { extensions: [skills] }); + * await client.connect(transport); + * + * const { skills: entries } = await skills.list(); + * const manifest = await skills.read(entries[0], entries[0].uri); + * ``` + * + * One extension instance serves one client: `install` binds it. + */ + +import type { BlobResourceContents, RequestOptions, TextResourceContents } from '@modelcontextprotocol/core-internal'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import type { + GetSkillResult, + ListSkillsParams, + ListSkillsResult, + ReadResourceDirectoryResult, + Skill, + SkillsExtensionCapability +} from '@modelcontextprotocol/core-internal/ext/skills'; +import { + getSkillResultSchema, + listSkillsResultSchema, + readResourceDirectoryResultSchema, + skillDigest, + skillFileBytes, + SKILLS_EXTENSION_ID, + skillsExtensionCapabilitySchema +} from '@modelcontextprotocol/core-internal/ext/skills'; + +import type { Client } from '../../client/client'; +import type { ClientExtension } from '../../client/extension'; + +const verificationFailure = (uri: string, reason: string): never => { + throw new SdkError(SdkErrorCode.InvalidResult, `Skill file ${uri} failed verification: ${reason}`); +}; + +const rawBytes = (content: TextResourceContents | BlobResourceContents): Uint8Array => + 'blob' in content ? Uint8Array.from(atob(content.blob), char => char.codePointAt(0) ?? 0) : skillFileBytes(content.text); + +export class SkillsClientExtension implements ClientExtension { + readonly id = SKILLS_EXTENSION_ID; + #client: Client | undefined; + + install(client: Client): void { + if (this.#client !== undefined) throw new Error('SkillsClientExtension is already installed on a client'); + this.#client = client; + } + + get client(): Client { + if (this.#client === undefined) throw new SdkError(SdkErrorCode.NotConnected, 'SkillsClientExtension is not installed on a client'); + return this.#client; + } + + /** The extension settings the server declared, or `undefined` when it declared none. */ + get capability(): SkillsExtensionCapability | undefined { + const declared = this.client.getServerCapabilities()?.extensions?.[SKILLS_EXTENSION_ID]; + const parsed = skillsExtensionCapabilitySchema.safeParse(declared); + return parsed.success ? parsed.data : undefined; + } + + /** `skills/list`: one page of entries. Pass `nextCursor` back as `cursor` for the next. */ + async list(params?: ListSkillsParams, options?: RequestOptions): Promise { + this.#require('skills/list'); + return (await this.client.request( + { method: 'skills/list', params: { ...params } }, + listSkillsResultSchema, + options + )) as ListSkillsResult; + } + + /** `skills/get`: the entry for the skill whose `SKILL.md` is at `uri`, listed or not. */ + async get(uri: string, options?: RequestOptions): Promise { + this.#require('skills/get'); + return (await this.client.request({ method: 'skills/get', params: { uri } }, getSkillResultSchema, options)) as GetSkillResult; + } + + /** `resources/directory/read`: the direct children of a directory resource. Refused unless the server declared `directoryRead`. */ + async readDirectory(uri: string, cursor?: string, options?: RequestOptions): Promise { + this.#require('resources/directory/read'); + if (this.capability?.directoryRead !== true) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Server does not support resources/directory/read'); + } + const params = { uri, ...(cursor !== undefined && { cursor }) }; + return (await this.client.request( + { method: 'resources/directory/read', params }, + readResourceDirectoryResultSchema, + options + )) as ReadResourceDirectoryResult; + } + + /** + * Reads one of `skill`'s files and verifies it against the entry: the file + * must be listed, and its size and digest must match. Throws `InvalidResult` + * on any mismatch. A `"dynamic"` skill has nothing to verify against, so its + * files are returned as read. Comparing a `SKILL.md`'s frontmatter with the + * entry needs a YAML parser and is left to the host. + */ + async read(skill: Skill, uri: string, options?: RequestOptions): Promise { + const listed = skill.resources === 'dynamic' ? undefined : skill.resources.find(resource => resource.uri === uri); + if (skill.resources !== 'dynamic' && listed === undefined) verificationFailure(uri, `not listed in ${skill.uri}`); + + const { contents } = await this.client.readResource({ uri }, options); + const content = contents.find(item => item.uri === uri) ?? verificationFailure(uri, 'not in the resources/read result'); + if (listed === undefined) return content; + + const bytes = rawBytes(content); + if (bytes.byteLength !== listed.size) verificationFailure(uri, `size ${bytes.byteLength}, entry says ${listed.size}`); + if ((await skillDigest(bytes)) !== listed.digest) verificationFailure(uri, 'digest mismatch'); + return content; + } + + #require(method: string): void { + if (this.capability === undefined) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support the ${SKILLS_EXTENSION_ID} extension (${method})` + ); + } + if (this.client.getServerCapabilities()?.resources === undefined) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server declares ${SKILLS_EXTENSION_ID} without resources (${method})`); + } + } +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8fc1de9347..61f006be8b 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -17,7 +17,8 @@ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], - "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"] + "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"], + "@modelcontextprotocol/core-internal/ext/skills": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts"] } } } diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 1a1229fedf..205518dff4 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/stdio.ts', + 'src/ext/skills/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', @@ -28,6 +29,7 @@ export default defineConfig({ 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], + '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 86b79e20b9..3df50201d7 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -33,6 +33,10 @@ "types": "./src/exports/public/index.ts", "import": "./src/exports/public/index.ts" }, + "./ext/skills": { + "types": "./src/ext/skills/index.ts", + "import": "./src/ext/skills/index.ts" + }, "./validators/ajv": { "types": "./src/validators/ajvProvider.ts", "import": "./src/validators/ajvProvider.ts" diff --git a/packages/core-internal/src/ext/skills/digest.ts b/packages/core-internal/src/ext/skills/digest.ts new file mode 100644 index 0000000000..bf2634203c --- /dev/null +++ b/packages/core-internal/src/ext/skills/digest.ts @@ -0,0 +1,20 @@ +import type { SkillResource } from './types'; + +const encoder = new TextEncoder(); + +/** The raw bytes of a file: UTF-8 for text. */ +export function skillFileBytes(content: string | Uint8Array): Uint8Array { + return typeof content === 'string' ? encoder.encode(content) : content; +} + +/** `sha256:{hex}` of raw bytes, the digest format of a `SkillResource`. */ +export async function skillDigest(bytes: Uint8Array): Promise { + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as Uint8Array)); + return `sha256:${Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('')}`; +} + +/** The `SkillResource` entry for a file: its URI, digest and size. */ +export async function skillResourceOf(uri: string, content: string | Uint8Array): Promise { + const bytes = skillFileBytes(content); + return { uri, digest: await skillDigest(bytes), size: bytes.byteLength }; +} diff --git a/packages/core-internal/src/ext/skills/index.ts b/packages/core-internal/src/ext/skills/index.ts new file mode 100644 index 0000000000..3cae6115ac --- /dev/null +++ b/packages/core-internal/src/ext/skills/index.ts @@ -0,0 +1,8 @@ +/** + * Wire types, zod schemas and digest helpers of the MCP Skills extension + * (`io.modelcontextprotocol/skills`, SEP-2640), shared by the server and + * client halves of the extension. + */ +export * from './digest'; +export * from './schemas'; +export * from './types'; diff --git a/packages/core-internal/src/ext/skills/schemas.ts b/packages/core-internal/src/ext/skills/schemas.ts new file mode 100644 index 0000000000..464cf2c2de --- /dev/null +++ b/packages/core-internal/src/ext/skills/schemas.ts @@ -0,0 +1,97 @@ +/* + * Zod schemas for the MCP Skills extension wire types (./types), authored + * against modelcontextprotocol/ext-skills `specification/stable/skills.mdx` + * (SEP-2640) at commit 167da6c. Adapted from typescript-sdk#2818. + * + * Objects are loose so `_meta` and fields added by later revisions pass + * through. `resultType` is not declared: the 2026-07-28 codec stamps and + * consumes it. The 512-entry / 16 MiB limits are a floor hosts must accept, + * not a ceiling, so the schemas do not enforce them. + * + * Copyright (c) Model Context Protocol contributors + */ + +import * as z from 'zod/v4'; + +import { ResourceSchema } from '../../types/schemas'; +import { SKILL_DIGEST_PATTERN, SKILL_MANIFEST_FILENAME } from './types'; + +const metaSchema = z.record(z.string(), z.unknown()); +const cacheFields = { ttlMs: z.number().int().nonnegative(), cacheScope: z.enum(['public', 'private']) }; + +/** `SkillsExtensionCapability` */ +export const skillsExtensionCapabilitySchema = z.looseObject({ directoryRead: z.boolean().optional() }); + +/** `SkillResource` */ +export const skillResourceSchema = z.looseObject({ + uri: z.string(), + digest: z.string().regex(SKILL_DIGEST_PATTERN, 'digest must be "sha256:" followed by 64 lowercase hex characters'), + size: z.number().int().nonnegative() +}); + +/** `SkillFrontmatter` */ +export const skillFrontmatterSchema = z.looseObject({ name: z.string(), description: z.string() }); + +const MANIFEST_SUFFIX = `/${SKILL_MANIFEST_FILENAME}`; + +/** + * `Skill`, with the structural rules every entry must meet: `uri` names a + * `SKILL.md`, its directory ends in `frontmatter.name`, and a static + * `resources` lists that `SKILL.md` and only files under the skill. + */ +export const skillSchema = z + .looseObject({ + uri: z.string(), + frontmatter: skillFrontmatterSchema, + resources: z.union([z.array(skillResourceSchema), z.literal('dynamic')]) + }) + .superRefine((skill, ctx) => { + if (!skill.uri.endsWith(MANIFEST_SUFFIX)) { + ctx.addIssue({ code: 'custom', path: ['uri'], message: `skill uri must name its ${SKILL_MANIFEST_FILENAME}` }); + return; + } + const root = skill.uri.slice(0, -MANIFEST_SUFFIX.length); + if (root.slice(root.lastIndexOf('/') + 1) !== skill.frontmatter.name) { + ctx.addIssue({ code: 'custom', path: ['uri'], message: 'the final skill path segment must equal frontmatter.name' }); + } + if (skill.resources === 'dynamic') return; + if (!skill.resources.some(resource => resource.uri === skill.uri)) { + ctx.addIssue({ code: 'custom', path: ['resources'], message: `resources must list the skill's ${SKILL_MANIFEST_FILENAME}` }); + } + for (const [index, resource] of skill.resources.entries()) { + if (!resource.uri.startsWith(`${root}/`)) { + ctx.addIssue({ code: 'custom', path: ['resources', index, 'uri'], message: 'resource is outside the skill directory' }); + } + } + }); + +/** `ListSkillsParams` */ +export const listSkillsParamsSchema = z.looseObject({ cursor: z.string().optional(), _meta: metaSchema.optional() }); + +/** `ListSkillsResult` */ +export const listSkillsResultSchema = z.looseObject({ + skills: z.array(skillSchema), + nextCursor: z.string().optional(), + ...cacheFields, + _meta: metaSchema.optional() +}); + +/** `GetSkillParams` */ +export const getSkillParamsSchema = z.looseObject({ uri: z.string(), _meta: metaSchema.optional() }); + +/** `GetSkillResult` */ +export const getSkillResultSchema = z.looseObject({ skill: skillSchema, ...cacheFields, _meta: metaSchema.optional() }); + +/** `ReadResourceDirectoryParams` */ +export const readResourceDirectoryParamsSchema = z.looseObject({ + uri: z.string(), + cursor: z.string().optional(), + _meta: metaSchema.optional() +}); + +/** `ReadResourceDirectoryResult` */ +export const readResourceDirectoryResultSchema = z.looseObject({ + resources: z.array(ResourceSchema), + nextCursor: z.string().optional(), + _meta: metaSchema.optional() +}); diff --git a/packages/core-internal/src/ext/skills/types.ts b/packages/core-internal/src/ext/skills/types.ts new file mode 100644 index 0000000000..e6e7e7f5bf --- /dev/null +++ b/packages/core-internal/src/ext/skills/types.ts @@ -0,0 +1,106 @@ +/* + * MCP Skills extension wire types (extension id: io.modelcontextprotocol/skills). + * + * Authored against modelcontextprotocol/ext-skills `specification/stable/skills.mdx` + * (SEP-2640) at commit 167da6c, base revision 2026-07-28. Constants and + * naming adapted from typescript-sdk#2818. + * https://github.com/modelcontextprotocol/ext-skills + * + * Copyright (c) Model Context Protocol contributors + */ + +import type { Resource, Result } from '../../types/index'; + +/** The MCP Skills extension identifier. */ +export const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; + +/** The file every skill has at its root; a skill is addressed by this file's URI. */ +export const SKILL_MANIFEST_FILENAME = 'SKILL.md'; + +/** `mimeType` of a directory resource (`resources/directory/read`). */ +export const DIRECTORY_MIME_TYPE = 'inode/directory'; + +/** Per-skill limit on `resources` entries, `SKILL.md` included. Hosts MUST accept up to this. */ +export const MAX_SKILL_RESOURCES = 512; + +/** Per-skill limit on the sum of `size` over `resources`: 16 MiB. Hosts MUST accept up to this. */ +export const MAX_SKILL_TOTAL_BYTES = 16_777_216; + +/** `sha256:` followed by 64 lowercase hex characters. */ +export const SKILL_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; + +/** Capability settings under `capabilities.extensions["io.modelcontextprotocol/skills"]`. `{}` declares support with no optional features. */ +export interface SkillsExtensionCapability { + /** The server implements `resources/directory/read`. Default `false`. */ + directoryRead?: boolean; + [key: string]: unknown; +} + +/** A file belonging to a skill, with the digest and size of its content. */ +export interface SkillResource { + /** Resource URI of the file. */ + uri: string; + /** SHA-256 digest of the file's raw bytes, `sha256:{64 lowercase hex}`. */ + digest: string; + /** Length in bytes of the file's raw content (the bytes `digest` covers). */ + size: number; +} + +/** A skill's `SKILL.md` YAML frontmatter, verbatim as JSON. `name` and `description` are always present. */ +export interface SkillFrontmatter { + name: string; + description: string; + [key: string]: unknown; +} + +/** The entry for one skill, returned by both `skills/list` and `skills/get`. */ +export interface Skill { + /** Resource URI of the skill's `SKILL.md`, readable via `resources/read`. */ + uri: string; + /** The `SKILL.md` frontmatter, verbatim. */ + frontmatter: SkillFrontmatter; + /** Every file of the skill (`SKILL.md` included), or `"dynamic"` when stable digests cannot be published. */ + resources: SkillResource[] | 'dynamic'; +} + +/** Where a cacheable result may be cached. */ +export type SkillsCacheScope = 'public' | 'private'; + +/** `skills/list` params: standard list pagination. */ +export interface ListSkillsParams { + cursor?: string; +} + +/** `skills/list` result. `ttlMs` and `cacheScope` are required, as on `resources/list`. */ +export interface ListSkillsResult extends Result { + skills: Skill[]; + nextCursor?: string; + ttlMs: number; + cacheScope: SkillsCacheScope; +} + +/** `skills/get` params. */ +export interface GetSkillParams { + /** URI of the skill's `SKILL.md`. */ + uri: string; +} + +/** `skills/get` result. `ttlMs` and `cacheScope` are required, as on `resources/read`. */ +export interface GetSkillResult extends Result { + skill: Skill; + ttlMs: number; + cacheScope: SkillsCacheScope; +} + +/** `resources/directory/read` params. */ +export interface ReadResourceDirectoryParams { + /** URI of the directory resource, no trailing slash. */ + uri: string; + cursor?: string; +} + +/** `resources/directory/read` result: the directory's direct children. Subdirectories carry `mimeType: "inode/directory"`. */ +export interface ReadResourceDirectoryResult extends Result { + resources: Resource[]; + nextCursor?: string; +} diff --git a/packages/core-internal/test/ext/skills/schemas.test.ts b/packages/core-internal/test/ext/skills/schemas.test.ts new file mode 100644 index 0000000000..c99533a35c --- /dev/null +++ b/packages/core-internal/test/ext/skills/schemas.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { MAX_SKILL_RESOURCES, skillResourceOf, skillSchema } from '../../../src/ext/skills/index'; + +const URI = 'skill://acme/billing/refunds/SKILL.md'; +const frontmatter = { name: 'refunds', description: 'Process customer refund requests', license: 'Apache-2.0' }; +const manifest = await skillResourceOf(URI, '---\nname: refunds\n---\n'); + +describe('skillSchema', () => { + it('accepts a static entry, keeping every frontmatter field', () => { + const parsed = skillSchema.parse({ uri: URI, frontmatter, resources: [manifest] }); + expect(parsed.frontmatter).toEqual(frontmatter); + }); + + it('accepts "dynamic" resources', () => { + expect(skillSchema.safeParse({ uri: URI, frontmatter, resources: 'dynamic' }).success).toBe(true); + }); + + it('does not cap resources at the spec baseline, which hosts may exceed', () => { + const extra = Array.from({ length: MAX_SKILL_RESOURCES }, (_, i) => ({ + ...manifest, + uri: `skill://acme/billing/refunds/f${i}.md` + })); + expect(skillSchema.safeParse({ uri: URI, frontmatter, resources: [manifest, ...extra] }).success).toBe(true); + }); + + it.each([ + ['a uri that does not name SKILL.md', { uri: 'skill://acme/billing/refunds', resources: [manifest] }], + ['a final path segment other than frontmatter.name', { uri: 'skill://acme/billing/refund/SKILL.md', resources: 'dynamic' }], + ['resources without the SKILL.md', { uri: URI, resources: [] }], + ['a resource outside the skill directory', { uri: URI, resources: [manifest, { ...manifest, uri: 'skill://acme/other.md' }] }], + ['a digest not in sha256:{hex} form', { uri: URI, resources: [{ ...manifest, digest: manifest.digest.toUpperCase() }] }], + ['resources missing entirely', { uri: URI }] + ])('rejects %s', (_, entry) => { + expect(skillSchema.safeParse({ frontmatter, ...entry }).success).toBe(false); + }); +}); diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 979ff15070..b3533aedc2 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -37,11 +37,11 @@ function readManifest(relativeDir: string): PackageManifest { const PUBLIC_PACKAGES: Record }> = { client: { name: '@modelcontextprotocol/client', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + exportKeys: ['.', './stdio', './ext/skills', './validators/ajv', './validators/cf-worker', './_shims'] }, server: { name: '@modelcontextprotocol/server', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + exportKeys: ['.', './stdio', './ext/skills', './validators/ajv', './validators/cf-worker', './_shims'] }, 'server-legacy': { name: '@modelcontextprotocol/server-legacy', diff --git a/packages/server/package.json b/packages/server/package.json index 652683ed0a..2144e5b6de 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -40,6 +40,16 @@ "default": "./dist/stdio.cjs" } }, + "./ext/skills": { + "import": { + "types": "./dist/ext/skills/index.d.mts", + "default": "./dist/ext/skills/index.mjs" + }, + "require": { + "types": "./dist/ext/skills/index.d.cts", + "default": "./dist/ext/skills/index.cjs" + } + }, "./validators/ajv": { "import": { "types": "./dist/validators/ajv.d.mts", @@ -115,6 +125,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "ext/skills": [ + "dist/ext/skills/index.d.mts" ] } }, @@ -141,6 +154,7 @@ "ajv": "catalog:runtimeShared", "ajv-formats": "catalog:runtimeShared", "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", "@modelcontextprotocol/eslint-config": "workspace:^", "@modelcontextprotocol/test-helpers": "workspace:^", diff --git a/packages/server/src/ext/skills/index.ts b/packages/server/src/ext/skills/index.ts new file mode 100644 index 0000000000..418b22b90a --- /dev/null +++ b/packages/server/src/ext/skills/index.ts @@ -0,0 +1,46 @@ +/** + * `@modelcontextprotocol/server/ext/skills` — the server side of the MCP + * Skills extension (`io.modelcontextprotocol/skills`, SEP-2640). + * + * `SkillsExtension` owns the wire: capability, `skills/list`, `skills/get` + * and `resources/directory/read`. A `SkillSource` owns the catalog. Skill + * files are ordinary resources; `skillResourceOf` computes the digest and + * size an entry lists for each. + */ + +export type { ReadResourceDirectoryPage, SkillsExtensionOptions, SkillSource } from './skillsExtension'; +export { SkillsExtension } from './skillsExtension'; +export type { + GetSkillParams, + GetSkillResult, + ListSkillsParams, + ListSkillsResult, + ReadResourceDirectoryParams, + ReadResourceDirectoryResult, + Skill, + SkillFrontmatter, + SkillResource, + SkillsCacheScope, + SkillsExtensionCapability +} from '@modelcontextprotocol/core-internal/ext/skills'; +export { + getSkillParamsSchema, + getSkillResultSchema, + listSkillsParamsSchema, + listSkillsResultSchema, + readResourceDirectoryParamsSchema, + readResourceDirectoryResultSchema, + skillFrontmatterSchema, + skillResourceSchema, + skillSchema, + skillsExtensionCapabilitySchema +} from '@modelcontextprotocol/core-internal/ext/skills'; +export { + DIRECTORY_MIME_TYPE, + MAX_SKILL_RESOURCES, + MAX_SKILL_TOTAL_BYTES, + SKILL_MANIFEST_FILENAME, + skillDigest, + skillResourceOf, + SKILLS_EXTENSION_ID +} from '@modelcontextprotocol/core-internal/ext/skills'; diff --git a/packages/server/src/ext/skills/skillsExtension.ts b/packages/server/src/ext/skills/skillsExtension.ts new file mode 100644 index 0000000000..5a2688e409 --- /dev/null +++ b/packages/server/src/ext/skills/skillsExtension.ts @@ -0,0 +1,130 @@ +/** + * `SkillsExtension` — the server side of the MCP Skills extension + * (`io.modelcontextprotocol/skills`, SEP-2640) as a {@linkcode ServerExtension}. + * It owns the wire: the capability, `skills/list`, `skills/get`, and + * `resources/directory/read` when the source can read directories. The + * skills themselves come from the {@link SkillSource} the server passes in; + * their files are ordinary resources, registered and read the usual way. + * + * ```ts + * const skills = new SkillsExtension({ + * list: () => ({ skills: [entry] }), + * get: ({ uri }) => (uri === entry.uri ? entry : undefined) + * }); + * const server = new McpServer(info, { extensions: [skills] }); + * server.registerResource('git-workflow', entry.uri, { mimeType: 'text/markdown' }, uri => ({ + * contents: [{ uri: uri.href, mimeType: 'text/markdown', text: skillMd }] + * })); + * ``` + */ + +import type { Resource, ServerContext } from '@modelcontextprotocol/core-internal'; +import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core-internal'; +import type { + GetSkillParams, + GetSkillResult, + ListSkillsParams, + ListSkillsResult, + ReadResourceDirectoryParams, + ReadResourceDirectoryResult, + Skill, + SkillsCacheScope +} from '@modelcontextprotocol/core-internal/ext/skills'; +import { + getSkillParamsSchema, + listSkillsParamsSchema, + readResourceDirectoryParamsSchema, + readResourceDirectoryResultSchema, + SKILLS_EXTENSION_ID, + skillSchema +} from '@modelcontextprotocol/core-internal/ext/skills'; + +import type { ServerExtension } from '../../server/extension'; +import type { Server } from '../../server/server'; + +/** + * Where a server's skills come from. Each method receives the request's + * params and context; pagination and any per-caller view are the source's. + */ +export interface SkillSource { + /** A page of skill entries. An empty or partial listing is allowed. */ + list( + params: ListSkillsParams, + ctx: ServerContext + ): { skills: Skill[]; nextCursor?: string } | Promise<{ skills: Skill[]; nextCursor?: string }>; + /** The entry for the skill whose `SKILL.md` is at `params.uri`, listed or not; `undefined` when none is served. */ + get(params: GetSkillParams, ctx: ServerContext): Skill | undefined | Promise; + /** The direct children of a directory resource; `undefined` when `params.uri` is not one. Implementing it advertises `directoryRead`. */ + readDirectory?( + params: ReadResourceDirectoryParams, + ctx: ServerContext + ): ReadResourceDirectoryPage | undefined | Promise; +} + +/** A page of `resources/directory/read` children. */ +export interface ReadResourceDirectoryPage { + resources: Resource[]; + nextCursor?: string; +} + +/** Options for {@link SkillsExtension}. */ +export interface SkillsExtensionOptions { + /** `ttlMs` and `cacheScope` for `skills/list` and `skills/get` results. Default `{ ttlMs: 0, cacheScope: 'private' }`. */ + cacheHint?: { ttlMs?: number; cacheScope?: SkillsCacheScope }; +} + +const invalidEntry = (error: unknown): never => { + throw new ProtocolError(ProtocolErrorCode.InternalError, `Skill source returned an invalid entry: ${String(error)}`); +}; + +const validEntry = (skill: Skill): Skill => { + const parsed = skillSchema.safeParse(skill); + return parsed.success ? (parsed.data as Skill) : invalidEntry(parsed.error); +}; + +export class SkillsExtension implements ServerExtension { + readonly id = SKILLS_EXTENSION_ID; + readonly source: SkillSource; + readonly #cache: { ttlMs: number; cacheScope: SkillsCacheScope }; + + constructor(source: SkillSource, options?: SkillsExtensionOptions) { + this.source = source; + this.#cache = { ttlMs: options?.cacheHint?.ttlMs ?? 0, cacheScope: options?.cacheHint?.cacheScope ?? 'private' }; + } + + install(server: Server): void { + const directoryRead = this.source.readDirectory !== undefined; + // Skill files are resources, so the extension requires the resources capability. + server.registerCapabilities({ resources: {}, extensions: { [SKILLS_EXTENSION_ID]: directoryRead ? { directoryRead } : {} } }); + + server.setRequestHandler('skills/list', { params: listSkillsParamsSchema }, async (params, ctx): Promise => { + const page = await this.source.list(params, ctx); + return { + skills: page.skills.map(skill => validEntry(skill)), + ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + ...this.#cache + }; + }); + + server.setRequestHandler('skills/get', { params: getSkillParamsSchema }, async (params, ctx): Promise => { + const skill = await this.source.get(params, ctx); + if (skill === undefined) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `No skill is served at ${params.uri}`); + return { skill: validEntry(skill), ...this.#cache }; + }); + + const readDirectory = this.source.readDirectory?.bind(this.source); + if (readDirectory === undefined) return; + server.setRequestHandler( + 'resources/directory/read', + { params: readResourceDirectoryParamsSchema }, + async (params, ctx): Promise => { + const page = await readDirectory(params, ctx); + if (page === undefined) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `${params.uri} is not a directory resource`); + } + const parsed = readResourceDirectoryResultSchema.safeParse(page); + return parsed.success ? (parsed.data as ReadResourceDirectoryResult) : invalidEntry(parsed.error); + } + ); + } +} diff --git a/packages/server/test/ext/skills/skills.e2e.test.ts b/packages/server/test/ext/skills/skills.e2e.test.ts new file mode 100644 index 0000000000..fd7918f1c2 --- /dev/null +++ b/packages/server/test/ext/skills/skills.e2e.test.ts @@ -0,0 +1,203 @@ +/** + * End to end through a real `Client` with `SkillsClientExtension` against + * `McpServer` with `SkillsExtension`, over the stateless `createMcpHandler`. + * Fixtures are the worked example of the SEP-2640 specification, so the + * digests and sizes asserted here are the spec's own. + */ +import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClientExtension } from '@modelcontextprotocol/client/ext/skills'; +import { describe, expect, it } from 'vitest'; + +import type { Skill, SkillSource } from '../../../src/ext/skills/index'; +import { DIRECTORY_MIME_TYPE, skillResourceOf, SKILLS_EXTENSION_ID, SkillsExtension } from '../../../src/ext/skills/index'; +import { CLIENT_CAPABILITIES_META_KEY, createMcpHandler, McpServer, PROTOCOL_VERSION_META_KEY } from '../../../src/index'; + +const SKILL_URI = 'skill://pdf-processing/SKILL.md'; +const FILES: Record = { + [SKILL_URI]: + '---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\n---\n\n# PDF processing\n\nChoose the matching template from `templates/`.\n', + 'skill://pdf-processing/templates/invoice.md': '# Invoice\n\nCustomer:\nAmount:\n', + 'skill://pdf-processing/templates/purchase-order.md': '# Purchase order\n\nSupplier:\nItems:\n' +}; + +const entry = async (): Promise => ({ + uri: SKILL_URI, + frontmatter: { name: 'pdf-processing', description: 'Extract, fill, and assemble PDF documents' }, + resources: await Promise.all(Object.entries(FILES).map(([uri, text]) => skillResourceOf(uri, text))) +}); + +const UNLISTED: Skill = { + uri: 'skill://acme/billing/refunds/SKILL.md', + frontmatter: { name: 'refunds', description: 'Process customer refund requests per company policy' }, + resources: 'dynamic' +}; + +const directorySource: Required> = { + readDirectory: ({ uri }) => + uri === 'skill://pdf-processing/templates' + ? { + resources: [ + { uri: 'skill://pdf-processing/templates/invoice.md', name: 'invoice.md', mimeType: 'text/markdown' }, + { uri: 'skill://pdf-processing/templates/regional', name: 'regional', mimeType: DIRECTORY_MIME_TYPE } + ] + } + : undefined +}; + +async function createHarness(options?: { source?: Partial; served?: Record; extension?: boolean }) { + const pdf = await entry(); + const source: SkillSource = { + list: ({ cursor }) => (cursor === undefined ? { skills: [pdf], nextCursor: 'page-2' } : { skills: [] }), + get: ({ uri }) => [pdf, UNLISTED].find(skill => skill.uri === uri), + ...directorySource, + ...options?.source + }; + const skills = new SkillsExtension(source, { cacheHint: { ttlMs: 300_000, cacheScope: 'public' } }); + const served = { ...FILES, ...options?.served }; + const createServer = () => { + const server = new McpServer( + { name: 'skills-test', version: '1.0.0' }, + { extensions: options?.extension === false ? [] : [skills] } + ); + for (const [uri, text] of Object.entries(served)) { + server.registerResource(uri, uri, { mimeType: 'text/markdown' }, () => ({ + contents: [{ uri, mimeType: 'text/markdown', text }] + })); + } + return server; + }; + const mcpHandler = createMcpHandler(createServer); + const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + fetch: (url, init) => mcpHandler.fetch(new Request(url, init)) + }); + const clientSkills = new SkillsClientExtension(); + const client = new Client({ name: 'harness', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' }, extensions: [clientSkills] }); + await client.connect(transport); + const raw = async (method: string, params: Record) => { + const response = await mcpHandler.fetch( + new Request('http://test.local/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': method + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: { [PROTOCOL_VERSION_META_KEY]: '2026-07-28', [CLIENT_CAPABILITIES_META_KEY]: {} } } + }) + }) + ); + const text = await response.text(); + const payload = response.headers.get('content-type')?.includes('text/event-stream') + ? text + .split('\n') + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trim()) + .at(-1) + : text; + return JSON.parse(payload ?? '{}') as { result?: Record; error?: { code: number; message: string } }; + }; + return { client, clientSkills, pdf, raw }; +} + +describe('SkillsExtension end to end', () => { + it('declares the extension with directoryRead, alongside resources', async () => { + const { client } = await createHarness(); + expect(client.getServerCapabilities()?.extensions).toEqual({ [SKILLS_EXTENSION_ID]: { directoryRead: true } }); + expect(client.getServerCapabilities()?.resources).toBeDefined(); + }); + + it('lists entries whose digests and sizes are the spec example values', async () => { + const { clientSkills } = await createHarness(); + const page = await clientSkills.list(); + expect(page).toMatchObject({ nextCursor: 'page-2', ttlMs: 300_000, cacheScope: 'public' }); + expect(page.skills).toEqual([ + { + uri: SKILL_URI, + frontmatter: { name: 'pdf-processing', description: 'Extract, fill, and assemble PDF documents' }, + resources: [ + { uri: SKILL_URI, digest: 'sha256:99b737495721155ece826d57521e2d66141ebdc1344a400487481ea2642ab19e', size: 151 }, + { + uri: 'skill://pdf-processing/templates/invoice.md', + digest: 'sha256:61f4ea6d2c75fde1b4977219e7e3107d491c3c26aefb6686e84d6281c088d9ee', + size: 29 + }, + { + uri: 'skill://pdf-processing/templates/purchase-order.md', + digest: 'sha256:f2ff774b1737ff3dec81c47946f9976f18a1a9f69dda0a81f22eabd95173c158', + size: 35 + } + ] + } + ]); + expect((await clientSkills.list({ cursor: 'page-2' })).skills).toEqual([]); + }); + + it('stamps resultType "complete" and the cache fields on the wire', async () => { + const { raw } = await createHarness(); + const { result } = await raw('skills/get', { uri: SKILL_URI }); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 300_000, cacheScope: 'public', skill: { uri: SKILL_URI } }); + }); + + it('gets a skill absent from the listing, and answers -32602 for one it does not serve', async () => { + const { clientSkills } = await createHarness(); + expect((await clientSkills.get(UNLISTED.uri)).skill).toEqual(UNLISTED); + await expect(clientSkills.get('skill://acme/billing/chargebacks/SKILL.md')).rejects.toMatchObject({ + code: -32602, + message: expect.stringContaining('No skill is served at skill://acme/billing/chargebacks/SKILL.md') + }); + }); + + it('answers -32603 when the source returns an entry that breaks the structural rules', async () => { + const { clientSkills, pdf } = await createHarness({ + source: { get: () => ({ ...pdf, frontmatter: { name: 'renamed', description: 'mismatch' } }) } + }); + await expect(clientSkills.get(SKILL_URI)).rejects.toMatchObject({ code: -32603 }); + }); + + it('reads a directory, and answers -32602 for a URI that is not one', async () => { + const { clientSkills } = await createHarness(); + const listing = await clientSkills.readDirectory('skill://pdf-processing/templates'); + expect(listing.resources.map(resource => [resource.name, resource.mimeType])).toEqual([ + ['invoice.md', 'text/markdown'], + ['regional', DIRECTORY_MIME_TYPE] + ]); + await expect(clientSkills.readDirectory(SKILL_URI)).rejects.toMatchObject({ code: -32602 }); + }); + + it('reads a listed file verified against the entry', async () => { + const { clientSkills, pdf } = await createHarness(); + const content = await clientSkills.read(pdf, 'skill://pdf-processing/templates/invoice.md'); + expect(content).toMatchObject({ text: '# Invoice\n\nCustomer:\nAmount:\n' }); + }); + + it('refuses a file whose bytes differ from the entry, and a file the entry does not list', async () => { + const { clientSkills, pdf } = await createHarness({ + served: { + 'skill://pdf-processing/templates/invoice.md': '# Invoice\n\nCustomer:\nAmount: 0\n', + 'skill://pdf-processing/templates/credit-note.md': '# Credit note\n' + } + }); + const invalid = { name: 'SdkError', code: SdkErrorCode.InvalidResult }; + await expect(clientSkills.read(pdf, 'skill://pdf-processing/templates/invoice.md')).rejects.toMatchObject(invalid); + await expect(clientSkills.read(pdf, 'skill://pdf-processing/templates/credit-note.md')).rejects.toMatchObject(invalid); + }); + + it('refuses directory reads when the server did not declare directoryRead', async () => { + const { clientSkills } = await createHarness({ source: { readDirectory: undefined } }); + await expect(clientSkills.readDirectory('skill://pdf-processing/templates')).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.CapabilityNotSupported + ); + }); + + it('refuses skills requests to a server that did not declare the extension', async () => { + const { clientSkills } = await createHarness({ extension: false }); + await expect(clientSkills.list()).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.CapabilityNotSupported + ); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 184ab7a899..2d9ea6db1d 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -17,7 +17,13 @@ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], - "@modelcontextprotocol/server/_shims": ["./src/shimsNode.ts"] + "@modelcontextprotocol/server/_shims": ["./src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ], + "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"] } } } diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index 88004cfc06..5c67dbb8eb 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/stdio.ts', + 'src/ext/skills/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', @@ -28,6 +29,7 @@ export default defineConfig({ 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], + '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3cf72692..634d19a14f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1797,6 +1797,9 @@ importers: '@eslint/js': specifier: catalog:devTools version: 9.39.4 + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../client '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../core-internal