fix: declare the skills extension in the Inspector's client capabilities (#2373)

SEP-2133 negotiates an extension from both sides, and a strict server
refuses skills/* to a client that did not declare
io.modelcontextprotocol/skills. The Inspector called those methods
without ever declaring it. Add it to ADVERTISABLE_EXTENSIONS, on by
default, which also gives it a Server Settings toggle.

Give the composable test server skillsRequireClientExtension and two
per-era strict showcase configs, so a strict server is reproducible here.

Writing the modern-era test exposed two defects the existing suite hid:
its "modern" cases set protocolEra on the transport config, which
InspectorClient does not read, so they connected on legacy; and once
they really ran modern, every skills result failed, because the modern
schemas required resultType, which the SDK codec checks and then lifts
off before a caller schema runs. Connect with versionNegotiation and
assert the era, and drop the resultType requirement (and the two modern
schema variants it was the only difference in).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3RsrgCxVoFPokLB4vAkxi
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-14 08:36:37 -04:00
commit 1d765b8af6
6 changed files with 396 additions and 150 deletions

View file

@ -0,0 +1,18 @@
{
"serverInfo": {
"name": "skills-strict-legacy",
"version": "1.0.0"
},
"tools": [
{
"preset": "echo"
}
],
"resources": [],
"skills": true,
"skillsRequireClientExtension": true,
"transport": {
"type": "streamable-http",
"port": 3232
}
}

View file

@ -0,0 +1,19 @@
{
"serverInfo": {
"name": "skills-strict-modern",
"version": "1.0.0"
},
"tools": [
{
"preset": "echo"
}
],
"resources": [],
"skills": true,
"skillsRequireClientExtension": true,
"transport": {
"type": "streamable-http",
"port": 3233,
"modern": true
}
}

View file

@ -614,6 +614,14 @@ export interface ServerConfig {
* refuse to send the call locally.
*/
skills?: boolean;
/**
* With {@link ServerConfig.skills}, answer `skills/list`, `skills/get` and
* `resources/directory/read` with `-32601` unless the client declared
* `io.modelcontextprotocol/skills` in its own capabilities — a strict
* SEP-2133 server (#2373). Off by default so the existing skills fixtures
* keep serving any client. Ignored without `skills`.
*/
skillsRequireClientExtension?: boolean;
/**
* Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the
* nested `elicitation` setting — the server-side half of the app-rendered
@ -1675,7 +1683,9 @@ export function createMcpServer(config: ServerConfig): McpServer {
// `skill://` half of resources/read. Wired after the SDK's own handlers so
// the resources/read wrapper can delegate non-skill URIs to them.
if (config.skills) {
wireSkillsHandlers(mcpServer);
wireSkillsHandlers(mcpServer, {
requireClientExtension: config.skillsRequireClientExtension,
});
}
// Extension-gated tools (#1739): start each gated tool disabled, then enable

View file

@ -87,6 +87,9 @@ export interface ConfigFile {
/** Advertise the Skills extension (SEP-2640) and serve its fixture skills,
* including `directoryRead` — see {@link ServerConfig.skills}. */
skills?: boolean;
/** With `skills`, refuse the skills methods to a client that did not declare
* the extension — see {@link ServerConfig.skillsRequireClientExtension}. */
skillsRequireClientExtension?: boolean;
/** Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the nested
* `elicitation` setting — the server half of app-rendered form elicitation
* (#1854). Pair with the `app_choose_option` tool + `choose_option_app` resource. */

View file

@ -91,6 +91,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
tasks: config.tasks,
tasksExtension: config.tasksExtension,
skills: config.skills,
skillsRequireClientExtension: config.skillsRequireClientExtension,
appElicitation: config.appElicitation,
maxPageSize: config.maxPageSize,
emptyStringCursor: config.emptyStringCursor,

View file

@ -59,6 +59,7 @@
import { createHash } from "node:crypto";
import * as z from "zod/v4";
import {
CLIENT_CAPABILITIES_META_KEY,
ProtocolError,
ProtocolErrorCode,
type McpServer,
@ -698,29 +699,106 @@ const DirectoryReadResultShape = z.object({
nextCursor: z.string().optional(),
});
export interface WireSkillsOptions {
/**
* Refuse the extension's own methods to a client that did not declare
* `io.modelcontextprotocol/skills` in its capabilities (#2373). SEP-2133
* negotiates an extension from both sides, and a strict server enforces the
* client's half; without this the fixture served every client, which is how
* an Inspector that never declared the extension passed against it.
*/
requireClientExtension?: boolean;
}
/**
* The request's `_meta` envelope as a handler receives it. The SDK types the
* envelope's keys as an empty object, so it is read as a plain record and each
* value is narrowed where it is used.
*/
type ReceivedEnvelope = Record<string, unknown> | undefined;
function hasSkillsExtension(capabilities: unknown): boolean {
if (typeof capabilities !== "object" || capabilities === null) return false;
const extensions = (capabilities as { extensions?: unknown }).extensions;
return (
typeof extensions === "object" &&
extensions !== null &&
SKILLS_EXTENSION_KEY in extensions
);
}
/**
* Throw `-32601` when the client did not declare the Skills extension.
*
* The two eras keep the declaration in different places. A modern
* (2026-07-28) request carries it in its own `_meta` envelope, which the SDK
* hands the handler as `ctx.mcpReq.envelope` — read first, because that is the
* per-request source of truth the SDK points handlers at. A legacy connection
* has no envelope, so the value `initialize` declared is the fallback.
*
* ⚠️ That fallback only exists on a **stateful** legacy connection. A legacy
* client reaching a server started with `modern` is served statelessly — a
* fresh instance per request that never saw `initialize` — so it has no
* declaration to read and is refused. That is why the strict showcase configs
* come one per era.
*/
function assertClientDeclaredSkills(
mcpServer: McpServer,
method: string,
envelope: ReceivedEnvelope,
): void {
const capabilities =
envelope?.[CLIENT_CAPABILITIES_META_KEY] ??
mcpServer.server.getClientCapabilities();
if (!hasSkillsExtension(capabilities)) {
throw new ProtocolError(
ProtocolErrorCode.MethodNotFound,
`${method} requires the client to declare ${SKILLS_EXTENSION_KEY} in its capabilities`,
);
}
}
/**
* Wire `skills/list`, `skills/get` and the `skill://` half of `resources/read`
* onto an `McpServer`.
*/
export function wireSkillsHandlers(mcpServer: McpServer): void {
export function wireSkillsHandlers(
mcpServer: McpServer,
options: WireSkillsOptions = {},
): void {
const lowLevel = mcpServer.server;
// Only the extension's own methods are gated. A `skill://` file is read
// through ordinary `resources/read`, a core method the client needs no
// extension to call.
const gate = (method: string, envelope: ReceivedEnvelope): void => {
if (options.requireClientExtension) {
assertClientDeclaredSkills(mcpServer, method, envelope);
}
};
lowLevel.setRequestHandler(
"skills/list",
{ params: ListSkillsParamsSchema, result: ListSkillsResultShape },
async (params) => listSkillsPage(params.cursor),
async (params, ctx) => {
gate("skills/list", ctx.mcpReq.envelope);
return listSkillsPage(params.cursor);
},
);
lowLevel.setRequestHandler(
"skills/get",
{ params: GetSkillParamsSchema, result: GetSkillResultShape },
async (params) => getSkillEntry(params.uri),
async (params, ctx) => {
gate("skills/get", ctx.mcpReq.envelope);
return getSkillEntry(params.uri);
},
);
lowLevel.setRequestHandler(
"resources/directory/read",
{ params: DirectoryReadParamsSchema, result: DirectoryReadResultShape },
async (params) => {
async (params, ctx) => {
gate("resources/directory/read", ctx.mcpReq.envelope);
const page = readDirectoryPage(params.uri, params.cursor);
// `-32602` for both "no such URI" and "exists but is not a directory",
// which is what SEP-2640 specifies — the same code `resources/read` uses